from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Request, Depends, Header, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, Response, FileResponse
import io
import csv
from pydantic import BaseModel
import pandas as pd
import os
import time
import shutil
import subprocess
from sqlalchemy import create_engine, text
from typing import List, Dict, Optional
import logging
from pathlib import Path
import random
import string
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from datetime import datetime, timedelta
import secrets
import hashlib
import tempfile
import json
import uuid
import re
from typing import AsyncIterator

# 导入qwen-agent
import sys
import os
# 添加qwen_tools_ues目录到Python路径
qwen_tools_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'qwen_tools_ues')
if os.path.exists(qwen_tools_path):
    sys.path.insert(0, qwen_tools_path)
else:
    # 如果相对路径不存在，尝试绝对路径
    qwen_tools_path = '/root/dev_folder/boid_database/qwen_tools_ues'
    if os.path.exists(qwen_tools_path):
        sys.path.insert(0, qwen_tools_path)

from agent_service import init_agent_service

# 配置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

# MySQL 连接配置
DATABASE_URL = os.getenv("DATABASE_URL", "mysql+pymysql://bioinfo_user:bioinfo_pass@localhost:3306/bioinfo")
engine = create_engine(DATABASE_URL, pool_pre_ping=True)

# 文件存储路径
UPLOAD_DIR = "/var/bioinfo/uploads"
BACKUP_DIR = "/var/bioinfo/backups"
os.makedirs(UPLOAD_DIR, exist_ok=True)
os.makedirs(BACKUP_DIR, exist_ok=True)

# PDB文件目录（绝对路径）
PDB_BASE_DIR = "/root/dev_folder/boid_database/data/pdb"
# 相对路径前缀（用于数据库存储）
PDB_RELATIVE_PREFIX = "../data/pdb"

# MySQL 连接信息（用于备份和恢复）
MYSQL_USER = os.getenv("MYSQL_USER", "bioinfo_user")
MYSQL_PASSWORD = os.getenv("MYSQL_PASSWORD", "bioinfo_pass")
MYSQL_DATABASE = os.getenv("MYSQL_DATABASE", "bioinfo")
MYSQL_HOST = os.getenv("MYSQL_HOST", "localhost")

# 文件上传配置（从环境变量读取，默认20MB）
MAX_UPLOAD_SIZE_MB = int(os.getenv("MAX_UPLOAD_SIZE_MB", "20"))
MAX_UPLOAD_SIZE_BYTES = MAX_UPLOAD_SIZE_MB * 1024 * 1024

# 开发模式配置
DEV_MODE = os.getenv("DEV_MODE", "false").lower() == "true"

# 邮箱验证配置
ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", "boidINFO@163.com")
SMTP_SERVER = os.getenv("SMTP_SERVER", "smtp.163.com")
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
SMTP_USERNAME = os.getenv("SMTP_USERNAME", "boidINFO@163.com")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "SNVQFwYfy4wgbgUW")
SECRET_KEY = os.getenv("SECRET_KEY", "SNVQFwYfy4wgbgUW")

# 身份验证过期时间配置
VERIFICATION_CODE_EXPIRE_MINUTES = int(os.getenv("VERIFICATION_CODE_EXPIRE_MINUTES", "10"))
TOKEN_EXPIRE_HOURS = int(os.getenv("TOKEN_EXPIRE_HOURS", "24"))

# 公网访问配置
BACKEND_HOST = os.getenv("BACKEND_HOST", "0.0.0.0")
BACKEND_PORT = int(os.getenv("BACKEND_PORT", "8000"))
ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://localhost:3000").split(",")

# 验证码和token存储（生产环境建议使用Redis）
verification_codes = {}  # {code: {"expires": datetime, "used": bool}}
active_tokens = {}  # {token: {"expires": datetime}}

# BLAST搜索配置
BLAST_DB_PATH = "/root/dev_folder/boid_database/blast_db/proteins_db"
BLAST_TIMEOUT = 60  # 秒
MAX_SEQUENCE_LENGTH = 10000  # 氨基酸
MAX_TARGET_SEQS = 500
TEMP_DIR = "/tmp/bioinfo"
os.makedirs(TEMP_DIR, exist_ok=True)

# Foldseek搜索配置
FOLDSEEK_DB_PATH = "/root/dev_folder/boid_database/foldseek_db/targetDB"
FOLDSEEK_PDB_DIR = "/root/dev_folder/boid_database/foldseek_db/pdb_files"
FOLDSEEK_TIMEOUT = 120  # 秒
MAX_PDB_FILE_SIZE = 10 * 1024 * 1024  # 10MB

# 搜索结果缓存（简单内存缓存，生产环境建议使用Redis）
search_cache = {}  # {query_hash: {"result": ..., "expires": timestamp}}

# Pydantic模型
class VerifyCodeRequest(BaseModel):
    code: str

app = FastAPI(title="生物信息数据库管理系统", version="1.0.0")

# 配置请求体大小限制
@app.middleware("http")
async def limit_upload_size(request: Request, call_next):
    """限制上传文件大小的中间件"""
    if request.method == "POST" and "/upload_csv/" in request.url.path:
        content_length = request.headers.get("content-length")
        if content_length:
            content_length = int(content_length)
            if content_length > MAX_UPLOAD_SIZE_BYTES:
                return HTTPException(
                    status_code=413,
                    detail=f"文件太大！最大允许 {MAX_UPLOAD_SIZE_MB}MB"
                )
    response = await call_next(request)
    return response

# CORS 配置
app.add_middleware(
    CORSMiddleware,
    allow_origins=ALLOWED_ORIGINS,  # 使用配置的允许来源
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# 启动事件：输出当前运行模式
@app.on_event("startup")
async def startup_event():
    """应用启动时输出配置信息"""
    logger.info("=" * 60)
    logger.info("🚀 生物信息数据库管理系统启动")
    logger.info("=" * 60)
    if DEV_MODE:
        logger.warning("⚠️  当前模式: 开发模式 (DEV_MODE=true)")
        logger.warning("⚠️  验证码将直接返回，不发送邮件")
        logger.warning("⚠️  生产环境请设置 DEV_MODE=false")
    else:
        logger.info("🔒 当前模式: 生产模式 (DEV_MODE=false)")
        logger.info(f"📧 验证码将发送到: {ADMIN_EMAIL}")
        logger.info("✅ 邮件发送失败将返回错误，不会泄露验证码")
    logger.info(f"📦 最大上传文件大小: {MAX_UPLOAD_SIZE_MB}MB")
    logger.info(f"⏰ 验证码有效期: {VERIFICATION_CODE_EXPIRE_MINUTES} 分钟")
    logger.info(f"⏰ 登录Token有效期: {TOKEN_EXPIRE_HOURS} 小时")
    logger.info(f"🌐 后端监听地址: {BACKEND_HOST}:{BACKEND_PORT}")
    logger.info(f"🔓 允许的跨域来源: {', '.join(ALLOWED_ORIGINS)}")
    logger.info("=" * 60)

# ============= 身份验证辅助函数 =============

def generate_verification_code() -> str:
    """生成6位数字验证码"""
    return ''.join(random.choices(string.digits, k=6))

def generate_token() -> str:
    """生成随机token"""
    return secrets.token_urlsafe(32)

def send_verification_email(code: str) -> bool:
    """发送验证码邮件"""
    try:
        msg = MIMEMultipart()
        msg['From'] = SMTP_USERNAME
        msg['To'] = ADMIN_EMAIL
        msg['Subject'] = '蛋白质数据库管理系统 - 验证码'
        
        body = f"""
        <html>
        <body style="font-family: Arial, sans-serif; padding: 20px;">
            <div style="max-width: 600px; margin: 0 auto; background: #f5f7fa; padding: 30px; border-radius: 10px;">
                <h2 style="color: #2c3e50; text-align: center;">🧬 蛋白质数据库管理系统</h2>
                <div style="background: white; padding: 20px; border-radius: 8px; margin: 20px 0;">
                    <p style="color: #606266; font-size: 16px;">您的验证码是：</p>
                    <div style="text-align: center; font-size: 32px; font-weight: bold; color: #409eff; letter-spacing: 8px; padding: 20px; background: #f0f2f5; border-radius: 6px; margin: 15px 0;">
                        {code}
                    </div>
                    <p style="color: #909399; font-size: 14px; margin-top: 15px;">
                        验证码有效期为 {VERIFICATION_CODE_EXPIRE_MINUTES} 分钟，请勿泄露给他人。
                    </p>
                </div>
                <p style="color: #909399; font-size: 12px; text-align: center;">
                    如果您没有请求此验证码，请忽略此邮件。
                </p>
            </div>
        </body>
        </html>
        """
        
        msg.attach(MIMEText(body, 'html'))
        
        with smtplib.SMTP(SMTP_SERVER, SMTP_PORT) as server:
            server.starttls()
            server.login(SMTP_USERNAME, SMTP_PASSWORD)
            server.send_message(msg)
        
        logger.info(f"验证码已发送到: {ADMIN_EMAIL}")
        return True
    except Exception as e:
        logger.error(f"发送邮件失败: {str(e)}")
        return False

def verify_token(authorization: Optional[str] = Header(None)) -> bool:
    """验证token中间件"""
    if not authorization or not authorization.startswith("Bearer "):
        raise HTTPException(status_code=401, detail="未提供认证token")
    
    token = authorization.replace("Bearer ", "")
    
    if token not in active_tokens:
        raise HTTPException(status_code=401, detail="无效的token")
    
    if datetime.now() > active_tokens[token]["expires"]:
        del active_tokens[token]
        raise HTTPException(status_code=401, detail="token已过期")
    
    return True

# ============= PDB文件处理 =============

def find_pdb_file(query_string: str) -> str:
    """
    根据 query 字符串查找对应的 PDB 文件
    返回相对路径，如果找不到则返回空字符串
    """
    if not query_string or pd.isna(query_string):
        return ""
    
    # 清理query字符串，移除可能的特殊字符
    query_clean = query_string.strip()
    
    # 构建可能的PDB文件名
    pdb_filename = f"{query_clean}.pdb"
    pdb_path = os.path.join(PDB_BASE_DIR, pdb_filename)
    
    # 检查文件是否存在
    if os.path.exists(pdb_path):
        # 返回相对路径
        return f"{PDB_RELATIVE_PREFIX}/{pdb_filename}"
    else:
        logger.warning(f"PDB file not found for query: {query_clean}")
        return ""


@app.get("/")
async def root():
    """健康检查接口"""
    return {
        "status": "ok", 
        "message": "生物信息数据库管理系统 API 正在运行",
        "max_upload_size_mb": MAX_UPLOAD_SIZE_MB
    }


@app.get("/config/")
async def get_config():
    """获取系统配置信息"""
    return {
        "max_upload_size_mb": MAX_UPLOAD_SIZE_MB,
        "pdb_base_dir": PDB_BASE_DIR,
        "upload_dir": UPLOAD_DIR,
        "backup_dir": BACKUP_DIR
    }


@app.post("/upload_csv/", dependencies=[Depends(verify_token)])
async def upload_csv(
    file: UploadFile = File(...),
    mode: str = Form("append")  # append: 追加数据, replace: 覆盖数据，从FormData读取
):
    """
    上传 CSV 文件并导入到数据库
    - 支持两种模式：追加(append)或覆盖(replace)
    - 自动创建备份
    - 验证数据格式
    - 排除 acr_source 和 query 列
    - 根据 query 字段匹配 PDB 文件路径
    - 记录导入历史
    - 返回详细的操作日志
    """
    logs = []  # 收集操作日志
    
    def add_log(message: str, level: str = "info"):
        """添加日志条目"""
        log_entry = {
            "timestamp": datetime.now().isoformat(),
            "level": level,
            "message": message
        }
        logs.append(log_entry)
        if level == "error":
            logger.error(message)
        elif level == "warning":
            logger.warning(message)
        else:
            logger.info(message)
    
    try:
        add_log(f"开始上传文件: {file.filename}")
        add_log(f"导入模式: {'覆盖现有数据' if mode == 'replace' else '追加到现有数据'}")
        # 验证文件类型
        if not file.filename.endswith('.csv'):
            add_log("错误：只支持 CSV 文件格式", "error")
            raise HTTPException(status_code=400, detail="只支持 CSV 文件")
        
        add_log("✓ 文件格式验证通过")
        
        # 验证文件大小（额外检查）
        file.file.seek(0, 2)  # 移动到文件末尾
        file_size = file.file.tell()  # 获取文件大小
        file.file.seek(0)  # 重置到文件开头
        
        if file_size > MAX_UPLOAD_SIZE_BYTES:
            add_log(f"错误：文件大小 {file_size / 1024 / 1024:.2f}MB 超过限制 {MAX_UPLOAD_SIZE_MB}MB", "error")
            raise HTTPException(
                status_code=413, 
                detail=f"文件太大！文件大小为 {file_size / 1024 / 1024:.2f}MB，最大允许 {MAX_UPLOAD_SIZE_MB}MB"
            )
        
        add_log(f"✓ 文件大小: {file_size / 1024 / 1024:.2f}MB，符合限制")
        
        # 保存上传的文件
        add_log("正在保存上传文件...")
        timestamp = int(time.time())
        file_path = os.path.join(UPLOAD_DIR, f"{timestamp}_{file.filename}")
        
        with open(file_path, "wb") as buffer:
            shutil.copyfileobj(file.file, buffer)
        
        add_log(f"✓ 文件已保存到: {file_path}")
        
        # 创建数据库备份
        add_log("正在创建数据库备份...")
        backup_file = os.path.join(BACKUP_DIR, f"backup_{timestamp}.sql")
        backup_cmd = f"mysqldump -h {MYSQL_HOST} -u {MYSQL_USER} -p{MYSQL_PASSWORD} {MYSQL_DATABASE} > {backup_file}"
        
        result = subprocess.run(backup_cmd, shell=True, capture_output=True, text=True)
        if result.returncode != 0:
            add_log(f"错误：数据库备份失败 - {result.stderr}", "error")
            raise HTTPException(status_code=500, detail=f"数据库备份失败: {result.stderr}")
        
        add_log(f"✓ 备份创建成功: {backup_file}")
        
        # 读取并验证 CSV 文件
        add_log("正在读取 CSV 文件...")
        try:
            df = pd.read_csv(file_path)
            add_log(f"✓ CSV 文件读取成功，共 {len(df)} 行数据")
            add_log(f"检测到列: {', '.join(df.columns.tolist())}")
        except Exception as e:
            add_log(f"错误：CSV 文件格式错误 - {str(e)}", "error")
            raise HTTPException(status_code=400, detail=f"CSV 文件格式错误: {str(e)}")
        
        # 验证必需的列
        add_log("正在验证数据列...")
        required_columns = ['Species', 'Assembly', 'Accession', 'The neighbor of', 'Type', 'Protein sequence', 'CDS']
        missing_columns = [col for col in required_columns if col not in df.columns]
        if missing_columns:
            add_log(f"错误：缺少必需的列 - {', '.join(missing_columns)}", "error")
            raise HTTPException(
                status_code=400, 
                detail=f"CSV 文件缺少必需的列: {', '.join(missing_columns)}"
            )
        
        add_log("✓ 所有必需列验证通过")
        
        # 处理数据：
        # 1. 保存 query 列用于匹配 PDB 文件
        # 2. 删除 acr_source 和 query 列
        # 3. 重命名列以匹配数据库字段
        
        add_log("正在处理数据...")
        
        # 如果存在 query 列，用它来查找 PDB 文件
        if 'query' in df.columns:
            add_log("正在匹配 PDB 文件...")
            df['pdb_file'] = df['query'].apply(find_pdb_file)
            # 统计找到的PDB文件数量
            pdb_found = df['pdb_file'].astype(bool).sum()
            add_log(f"✓ 找到 {pdb_found}/{len(df)} 个对应的 PDB 文件")
        else:
            df['pdb_file'] = ""
            add_log("⚠ 未找到 query 列，跳过 PDB 文件匹配", "warning")
        
        # 删除不需要的列
        columns_to_drop = []
        if 'acr_source' in df.columns:
            columns_to_drop.append('acr_source')
        if 'query' in df.columns:
            columns_to_drop.append('query')
        
        if columns_to_drop:
            df = df.drop(columns=columns_to_drop)
            add_log(f"✓ 已删除列: {', '.join(columns_to_drop)}")
        
        # 重命名列以匹配数据库字段名
        column_mapping = {
            'Species': 'species',
            'Assembly': 'assembly',
            'Accession': 'accession',
            'The neighbor of': 'the_neighbor_of',
            'Type': 'type',
            'Protein sequence': 'protein_sequence',
            'CDS': 'cds'
        }
        df = df.rename(columns=column_mapping)
        
        # 确保所有必需字段都有值
        df['species'] = df['species'].fillna('')
        df['assembly'] = df['assembly'].fillna('')
        df['accession'] = df['accession'].fillna('')
        df['the_neighbor_of'] = df['the_neighbor_of'].fillna('')
        df['type'] = df['type'].fillna('')
        df['protein_sequence'] = df['protein_sequence'].fillna('')
        df['cds'] = df['cds'].fillna('')
        df['pdb_file'] = df['pdb_file'].fillna('')
        
        add_log("✓ 数据处理完成")
        
        # 导入数据到数据库
        try:
            if mode == "replace":
                # 覆盖模式：先清空表，再导入
                add_log("⚠ 覆盖模式：正在清空现有数据...", "warning")
                with engine.connect() as conn:
                    conn.execute(text("TRUNCATE TABLE proteins"))
                    conn.commit()
                add_log("✓ 现有数据已清空")
                add_log(f"正在导入 {len(df)} 行新数据...")
                df.to_sql("proteins", con=engine, if_exists="append", index=False, method='multi', chunksize=1000)
                add_log(f"✓ 数据导入成功！共导入 {len(df)} 行")
            else:
                # 追加模式：直接追加数据
                add_log(f"正在追加 {len(df)} 行新数据到数据库...")
                df.to_sql("proteins", con=engine, if_exists="append", index=False, method='multi', chunksize=1000)
                add_log(f"✓ 数据追加成功！共追加 {len(df)} 行")
        except Exception as e:
            add_log(f"错误：数据导入失败 - {str(e)}", "error")
            # 如果是重复键错误，提供更详细的信息
            if 'Duplicate entry' in str(e):
                raise HTTPException(
                    status_code=400, 
                    detail=f"数据导入失败：存在重复的 Accession。请检查 CSV 文件中是否有重复的登录号。错误详情: {str(e)}"
                )
            raise HTTPException(status_code=500, detail=f"数据导入失败: {str(e)}")
        
        # 记录导入历史
        add_log("正在记录导入历史...")
        with engine.begin() as conn:
            conn.execute(
                text("""
                    INSERT INTO import_history 
                    (file_name, row_count, status, backup_file) 
                    VALUES (:file_name, :row_count, :status, :backup_file)
                """),
                {
                    "file_name": file.filename,
                    "row_count": len(df),
                    "status": "success",
                    "backup_file": backup_file
                }
            )
        
        add_log("✓ 导入历史记录已保存")
        
        # 统计PDB文件信息
        pdb_count = (df['pdb_file'] != '').sum()
        
        add_log(f"🎉 导入完成！共处理 {len(df)} 行数据，其中 {pdb_count} 个有对应 PDB 文件", "info")
        
        return {
            "success": True,
            "message": "CSV 导入成功",
            "filename": file.filename,
            "rows": len(df),
            "pdb_files_found": int(pdb_count),
            "backup_file": backup_file,
            "mode": mode,
            "logs": logs  # 返回详细日志
        }
    
    except HTTPException as he:
        add_log(f"❌ 导入失败: {he.detail}", "error")
        # 即使失败也返回日志
        return JSONResponse(
            status_code=he.status_code,
            content={
                "success": False,
                "message": he.detail,
                "logs": logs
            }
        )
    except Exception as e:
        add_log(f"❌ 未预期的错误: {str(e)}", "error")
        return JSONResponse(
            status_code=500,
            content={
                "success": False,
                "message": f"服务器内部错误: {str(e)}",
                "logs": logs
            }
        )


@app.get("/history/")
async def get_history() -> List[Dict]:
    """获取所有导入历史记录"""
    try:
        with engine.begin() as conn:
            result = conn.execute(
                text("""
                    SELECT id, file_name, import_time, row_count, status, backup_file 
                    FROM import_history 
                    ORDER BY id DESC
                """)
            )
            history = []
            for row in result:
                history.append({
                    "id": row[0],
                    "file_name": row[1],
                    "import_time": str(row[2]),
                    "row_count": row[3],
                    "status": row[4],
                    "backup_file": row[5]
                })
            return history
    except Exception as e:
        logger.error(f"获取历史记录失败: {str(e)}")
        raise HTTPException(status_code=500, detail=f"获取历史记录失败: {str(e)}")


@app.post("/rollback/{history_id}", dependencies=[Depends(verify_token)])
async def rollback(history_id: int):
    """回滚到指定的历史版本"""
    try:
        # 查询备份文件路径
        with engine.begin() as conn:
            result = conn.execute(
                text("SELECT backup_file, file_name FROM import_history WHERE id = :history_id"),
                {"history_id": history_id}
            )
            row = result.fetchone()
            
            if not row:
                raise HTTPException(status_code=404, detail="历史记录不存在")
            
            backup_file = row[0]
            file_name = row[1]
        
        # 检查备份文件是否存在
        if not os.path.exists(backup_file):
            raise HTTPException(status_code=404, detail="备份文件不存在或已被删除")
        
        logger.info(f"开始回滚到版本 {history_id}，备份文件: {backup_file}")
        
        # 执行回滚
        restore_cmd = f"mysql -h {MYSQL_HOST} -u {MYSQL_USER} -p{MYSQL_PASSWORD} {MYSQL_DATABASE} < {backup_file}"
        result = subprocess.run(restore_cmd, shell=True, capture_output=True, text=True)
        
        if result.returncode != 0:
            logger.error(f"回滚失败: {result.stderr}")
            raise HTTPException(status_code=500, detail=f"数据库恢复失败: {result.stderr}")
        
        logger.info(f"回滚成功到版本 {history_id}")
        
        return {
            "message": f"已成功回滚到历史版本 {history_id}",
            "history_id": history_id,
            "file_name": file_name,
            "backup_file": backup_file
        }
    
    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"回滚过程中发生错误: {str(e)}")
        raise HTTPException(status_code=500, detail=f"回滚失败: {str(e)}")


@app.get("/stats/")
async def get_stats():
    """获取数据库统计信息"""
    try:
        with engine.begin() as conn:
            # 获取蛋白质总数
            result = conn.execute(text("SELECT COUNT(*) FROM proteins"))
            protein_count = result.fetchone()[0]
            
            # 获取导入历史总数
            result = conn.execute(text("SELECT COUNT(*) FROM import_history"))
            import_count = result.fetchone()[0]
            
            # 获取物种数量
            result = conn.execute(text("SELECT COUNT(DISTINCT species) FROM proteins"))
            species_count = result.fetchone()[0]
            
            # 获取类型统计
            result = conn.execute(text("SELECT type, COUNT(*) as count FROM proteins GROUP BY type ORDER BY count DESC LIMIT 10"))
            type_stats = [{"type": row[0], "count": row[1]} for row in result]
            
            # 获取有PDB文件的数量
            result = conn.execute(text("SELECT COUNT(*) FROM proteins WHERE pdb_file != ''"))
            pdb_count = result.fetchone()[0]
            
            return {
                "protein_count": protein_count,
                "import_count": import_count,
                "species_count": species_count,
                "pdb_file_count": pdb_count,
                "type_statistics": type_stats
            }
    except Exception as e:
        logger.error(f"获取统计信息失败: {str(e)}")
        raise HTTPException(status_code=500, detail=f"获取统计信息失败: {str(e)}")


@app.post("/api/admin/build/blast", dependencies=[Depends(verify_token)])
async def build_blast_db():
    """构建BLAST数据库"""
    try:
        script_path = "/root/dev_folder/boid_database/code/scripts/build_blast_db.sh"
        if not os.path.exists(script_path):
            raise HTTPException(status_code=404, detail="BLAST构建脚本不存在")
        
        # 执行构建脚本
        result = subprocess.run(
            ["bash", script_path],
            capture_output=True,
            text=True,
            timeout=3600  # 1小时超时
        )
        
        if result.returncode != 0:
            logger.error(f"BLAST数据库构建失败: {result.stderr}")
            raise HTTPException(
                status_code=500, 
                detail=f"BLAST数据库构建失败: {result.stderr}"
            )
        
        # 读取元数据
        metadata_path = "/root/dev_folder/boid_database/blast_db/metadata.json"
        metadata = {}
        if os.path.exists(metadata_path):
            with open(metadata_path, 'r') as f:
                metadata = json.load(f)
        
        return {
            "success": True,
            "message": "BLAST数据库构建成功",
            "output": result.stdout,
            "metadata": metadata
        }
    except subprocess.TimeoutExpired:
        raise HTTPException(status_code=500, detail="构建超时，可能数据量过大")
    except Exception as e:
        logger.error(f"构建BLAST数据库失败: {str(e)}")
        raise HTTPException(status_code=500, detail=f"构建失败: {str(e)}")


@app.post("/api/admin/build/foldseek", dependencies=[Depends(verify_token)])
async def build_foldseek_db():
    """构建Foldseek数据库"""
    try:
        script_path = "/root/dev_folder/boid_database/code/scripts/build_foldseek_db.sh"
        if not os.path.exists(script_path):
            raise HTTPException(status_code=404, detail="Foldseek构建脚本不存在")
        
        # 执行构建脚本
        result = subprocess.run(
            ["bash", script_path],
            capture_output=True,
            text=True,
            timeout=7200  # 2小时超时
        )
        
        if result.returncode != 0:
            logger.error(f"Foldseek数据库构建失败: {result.stderr}")
            raise HTTPException(
                status_code=500, 
                detail=f"Foldseek数据库构建失败: {result.stderr}"
            )
        
        # 读取元数据
        metadata_path = "/root/dev_folder/boid_database/foldseek_db/metadata.json"
        metadata = {}
        if os.path.exists(metadata_path):
            with open(metadata_path, 'r') as f:
                metadata = json.load(f)
        
        return {
            "success": True,
            "message": "Foldseek数据库构建成功",
            "output": result.stdout,
            "metadata": metadata
        }
    except subprocess.TimeoutExpired:
        raise HTTPException(status_code=500, detail="构建超时，可能数据量过大")
    except Exception as e:
        logger.error(f"构建Foldseek数据库失败: {str(e)}")
        raise HTTPException(status_code=500, detail=f"构建失败: {str(e)}")


@app.get("/api/admin/db/status", dependencies=[Depends(verify_token)])
async def get_db_status():
    """获取数据库构建状态"""
    try:
        status = {
            "blast": {
                "exists": False,
                "metadata": None,
                "metadata_status": "missing",  # missing, valid, outdated
                "db_path": "/root/dev_folder/boid_database/blast_db/proteins_db",
                "message": None
            },
            "foldseek": {
                "exists": False,
                "metadata": None,
                "metadata_status": "missing",
                "db_path": "/root/dev_folder/boid_database/foldseek_db/targetDB",
                "message": None
            }
        }
        
        # 检查BLAST数据库
        blast_db_path = status["blast"]["db_path"]
        blast_metadata_path = "/root/dev_folder/boid_database/blast_db/metadata.json"
        blast_dir = os.path.dirname(blast_db_path)
        
        if os.path.exists(f"{blast_db_path}.phr") and os.path.exists(f"{blast_db_path}.psq"):
            status["blast"]["exists"] = True
            
            # 尝试读取元数据
            if os.path.exists(blast_metadata_path):
                try:
                    with open(blast_metadata_path, 'r') as f:
                        status["blast"]["metadata"] = json.load(f)
                    status["blast"]["metadata_status"] = "valid"
                except (json.JSONDecodeError, IOError) as e:
                    logger.warning(f"元数据文件格式错误: {e}")
                    status["blast"]["metadata_status"] = "invalid"
                    status["blast"]["message"] = "元数据文件存在但格式错误，建议重新构建数据库"
            else:
                # 数据库存在但无元数据 - 可能的原因
                status["blast"]["metadata_status"] = "missing"
                status["blast"]["message"] = (
                    "数据库已构建，但缺少元数据文件。"
                    "这可能是因为：1) 数据库是手动构建的；"
                    "2) 构建脚本在执行元数据生成步骤时被中断；"
                    "3) 元数据文件被意外删除。"
                    "建议重新构建数据库以生成完整元数据。"
                )
                
                # 尝试从数据库文件获取基本信息（可选）
                try:
                    # 尝试使用blastdbcmd获取序列数量
                    result = subprocess.run(
                        ["blastdbcmd", "-db", blast_db_path, "-info"],
                        capture_output=True,
                        text=True,
                        timeout=10
                    )
                    if result.returncode == 0:
                        # 解析输出获取序列数
                        for line in result.stdout.split('\n'):
                            if 'sequences' in line.lower():
                                # 尝试提取序列数量
                                match = re.search(r'(\d+)\s+sequences?', line)
                                if match:
                                    status["blast"]["metadata"] = {
                                        "sequence_count": int(match.group(1)),
                                        "note": "从数据库文件自动提取的信息，非完整元数据"
                                    }
                except Exception:
                    pass  # 如果无法获取，保持metadata为None
        
        # 检查Foldseek数据库
        foldseek_db_path = status["foldseek"]["db_path"]
        foldseek_metadata_path = "/root/dev_folder/boid_database/foldseek_db/metadata.json"
        foldseek_dir = os.path.dirname(foldseek_db_path)
        
        if os.path.exists(f"{foldseek_db_path}.dbtype"):
            status["foldseek"]["exists"] = True
            
            # 尝试读取元数据
            if os.path.exists(foldseek_metadata_path):
                try:
                    with open(foldseek_metadata_path, 'r') as f:
                        status["foldseek"]["metadata"] = json.load(f)
                    status["foldseek"]["metadata_status"] = "valid"
                except (json.JSONDecodeError, IOError) as e:
                    logger.warning(f"元数据文件格式错误: {e}")
                    status["foldseek"]["metadata_status"] = "invalid"
                    status["foldseek"]["message"] = "元数据文件存在但格式错误，建议重新构建数据库"
            else:
                # 数据库存在但无元数据
                status["foldseek"]["metadata_status"] = "missing"
                status["foldseek"]["message"] = (
                    "数据库已构建，但缺少元数据文件。"
                    "这可能是因为：1) 数据库是手动构建的；"
                    "2) 构建脚本在执行元数据生成步骤时被中断；"
                    "3) 元数据文件被意外删除。"
                    "建议重新构建数据库以生成完整元数据。"
                )
                
                # 尝试统计PDB文件数量（可选）
                try:
                    pdb_dir = "/root/dev_folder/boid_database/data/pdb"
                    if os.path.exists(pdb_dir):
                        pdb_count = len([f for f in os.listdir(pdb_dir) if f.endswith('.pdb')])
                        if pdb_count > 0:
                            # 获取数据库大小
                            db_size = "N/A"
                            if os.path.exists(foldseek_db_path):
                                try:
                                    result = subprocess.run(
                                        ["du", "-sh", foldseek_dir],
                                        capture_output=True,
                                        text=True,
                                        timeout=5
                                    )
                                    if result.returncode == 0:
                                        db_size = result.stdout.split()[0]
                                except:
                                    pass
                            
                            status["foldseek"]["metadata"] = {
                                "pdb_file_count": pdb_count,
                                "database_size": db_size,
                                "note": "从文件系统自动提取的信息，非完整元数据"
                            }
                except Exception:
                    pass  # 如果无法获取，保持metadata为None
        
        return status
    except Exception as e:
        logger.error(f"获取数据库状态失败: {str(e)}")
        raise HTTPException(status_code=500, detail=f"获取状态失败: {str(e)}")


@app.get("/api/search/")
@app.get("/search/")  # 保留旧路径以兼容性
async def search_proteins(
    q: str = Query("", max_length=100, description="搜索关键词"),
    species: str = Query("", max_length=255, description="物种筛选"),
    type: str = Query("", max_length=50, description="类型筛选"),
    page: int = Query(1, ge=1, le=10000, description="页码"),
    page_size: int = Query(20, ge=1, le=100, description="每页大小")
):
    """
    搜索蛋白质数据
    - q: 搜索关键词（在 species, accession, type 中搜索）
    - species: 按物种过滤
    - type: 按类型过滤
    - page: 页码
    - page_size: 每页大小
    """
    try:
        offset = (page - 1) * page_size
        
        # 构建查询条件
        conditions = []
        params = {}
        
        if q:
            conditions.append("(species LIKE :q OR accession LIKE :q OR type LIKE :q)")
            params["q"] = f"%{q}%"
        
        if species:
            conditions.append("species = :species")
            params["species"] = species
        
        if type:
            conditions.append("type = :type")
            params["type"] = type
        
        where_clause = " AND ".join(conditions) if conditions else "1=1"
        
        with engine.begin() as conn:
            # 获取总数
            count_query = f"SELECT COUNT(*) FROM proteins WHERE {where_clause}"
            result = conn.execute(text(count_query), params)
            total = result.fetchone()[0]
            
            # 获取数据
            query = f"""
                SELECT id, species, assembly, accession, the_neighbor_of, type, 
                       LEFT(protein_sequence, 100) as protein_sequence_preview,
                       pdb_file, created_at
                FROM proteins 
                WHERE {where_clause}
                ORDER BY id DESC
                LIMIT :limit OFFSET :offset
            """
            params["limit"] = page_size
            params["offset"] = offset
            
            result = conn.execute(text(query), params)
            
            proteins = []
            for row in result:
                proteins.append({
                    "id": row[0],
                    "species": row[1],
                    "assembly": row[2],
                    "accession": row[3],
                    "the_neighbor_of": row[4],
                    "type": row[5],
                    "protein_sequence_preview": row[6] + "..." if row[6] else "",
                    "pdb_file": row[7],
                    "has_pdb": bool(row[7] and row[7] != ""),
                    "created_at": str(row[8])
                })
            
            return {
                "total": total,
                "page": page,
                "page_size": page_size,
                "total_pages": (total + page_size - 1) // page_size,
                "data": proteins
            }
    
    except Exception as e:
        logger.error(f"搜索失败: {str(e)}")
        raise HTTPException(status_code=500, detail=f"搜索失败: {str(e)}")


@app.get("/api/search/export/")
async def export_search_results_csv(
    q: str = "",
    species: str = "",
    type: str = ""
):
    """
    导出搜索结果为CSV文件
    - q: 搜索关键词
    - species: 按物种过滤
    - type: 按类型过滤
    返回CSV格式文件，格式与上传CSV保持一致
    """
    try:
        # 构建查询条件（与搜索API相同）
        conditions = []
        params = {}
        
        if q:
            conditions.append("(species LIKE :q OR accession LIKE :q OR type LIKE :q)")
            params["q"] = f"%{q}%"
        
        if species:
            conditions.append("species = :species")
            params["species"] = species
        
        if type:
            conditions.append("type = :type")
            params["type"] = type
        
        where_clause = " AND ".join(conditions) if conditions else "1=1"
        
        with engine.begin() as conn:
            # 获取完整数据（包含完整序列和CDS）
            query = f"""
                SELECT species, assembly, accession, the_neighbor_of, type,
                       protein_sequence, cds
                FROM proteins 
                WHERE {where_clause}
                ORDER BY id DESC
            """
            
            result = conn.execute(text(query), params)
            
            # 创建CSV内容
            output = io.StringIO()
            writer = csv.writer(output)
            
            # 写入CSV头部（与上传格式一致）
            writer.writerow([
                'Species',
                'Assembly',
                'Accession',
                'The neighbor of',
                'Type',
                'Protein sequence',
                'CDS'
            ])
            
            # 写入数据行
            for row in result:
                writer.writerow([
                    row[0] or '',  # species
                    row[1] or '',  # assembly
                    row[2] or '',  # accession
                    row[3] or '',  # the_neighbor_of
                    row[4] or '',  # type
                    row[5] or '',  # protein_sequence
                    row[6] or ''   # cds
                ])
            
            # 获取CSV内容
            csv_content = output.getvalue()
            output.close()
            
            # 生成文件名（包含时间戳）
            timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
            filename = f"protein_search_results_{timestamp}.csv"
            
            # 返回CSV文件
            return Response(
                content=csv_content.encode('utf-8-sig'),  # 使用BOM确保Excel正确显示中文
                media_type="text/csv",
                headers={
                    "Content-Disposition": f"attachment; filename={filename}"
                }
            )
    
    except Exception as e:
        logger.error(f"导出CSV失败: {str(e)}")
        raise HTTPException(status_code=500, detail=f"导出CSV失败: {str(e)}")


@app.get("/protein/{protein_id}")
async def get_protein_detail(protein_id: int):
    """获取蛋白质详细信息"""
    try:
        with engine.begin() as conn:
            query = """
                SELECT id, species, assembly, accession, the_neighbor_of, type,
                       protein_sequence, cds, pdb_file, created_at, updated_at
                FROM proteins
                WHERE id = :protein_id
            """
            result = conn.execute(text(query), {"protein_id": protein_id})
            row = result.fetchone()
            
            if not row:
                raise HTTPException(status_code=404, detail="蛋白质记录不存在")
            
            return {
                "id": row[0],
                "species": row[1],
                "assembly": row[2],
                "accession": row[3],
                "the_neighbor_of": row[4],
                "type": row[5],
                "protein_sequence": row[6],
                "cds": row[7],
                "pdb_file": row[8],
                "has_pdb": bool(row[8] and row[8] != ""),
                "created_at": str(row[9]),
                "updated_at": str(row[10])
            }
    
    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"获取蛋白质详情失败: {str(e)}")
        raise HTTPException(status_code=500, detail=f"获取蛋白质详情失败: {str(e)}")


@app.get("/autocomplete/")
async def autocomplete(field: str, q: str = "", limit: int = 10):
    """
    自动补全接口
    - field: species, type, accession
    - q: 搜索关键词（可为空，显示所有选项）
    - limit: 返回数量
    """
    try:
        allowed_fields = ["species", "type", "accession"]
        if field not in allowed_fields:
            raise HTTPException(status_code=400, detail=f"不支持的字段: {field}")
        
        with engine.begin() as conn:
            # 如果搜索关键词为空，返回所有不重复值的前N条
            if not q or q.strip() == "":
                query = f"""
                    SELECT DISTINCT {field}
                    FROM proteins
                    WHERE {field} IS NOT NULL AND {field} != ''
                    ORDER BY {field} ASC
                    LIMIT :limit
                """
                params = {"limit": limit}
            else:
                # 如果有关键词，使用LIKE搜索
                query = f"""
                    SELECT DISTINCT {field}
                    FROM proteins
                    WHERE {field} IS NOT NULL AND {field} != '' AND {field} LIKE :q
                    ORDER BY {field} ASC
                    LIMIT :limit
                """
                params = {"q": f"%{q.strip()}%", "limit": limit}
            
            result = conn.execute(text(query), params)
            
            suggestions = [row[0] for row in result if row[0]]
            return {"suggestions": suggestions}
    
    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"自动补全失败: {str(e)}")
        raise HTTPException(status_code=500, detail=f"自动补全失败: {str(e)}")


@app.get("/pdb/{protein_id}")
async def get_pdb_file(protein_id: int):
    """获取PDB文件内容（用于前端显示）"""
    try:
        with engine.begin() as conn:
            result = conn.execute(
                text("SELECT pdb_file FROM proteins WHERE id = :protein_id"),
                {"protein_id": protein_id}
            )
            row = result.fetchone()
            
            if not row:
                logger.warning(f"蛋白质ID {protein_id} 不存在")
                raise HTTPException(status_code=404, detail=f"蛋白质ID {protein_id} 不存在")
            
            if not row[0] or row[0] == "":
                logger.warning(f"蛋白质ID {protein_id} 没有关联的PDB文件")
                raise HTTPException(status_code=404, detail=f"该蛋白质没有3D结构文件")
            
            # 构建完整的PDB文件路径
            pdb_relative_path = row[0]
            logger.info(f"PDB相对路径: {pdb_relative_path}")
            
            # 将相对路径转换为绝对路径
            # 相对路径是 ../data/pdb/xxx.pdb，从 code 目录向上一级到 boid_database 目录
            if pdb_relative_path.startswith("../"):
                # 从 /root/dev_folder/boid_database/code 向上一级
                pdb_absolute_path = os.path.join(
                    "/root/dev_folder/boid_database",
                    pdb_relative_path.replace("../", "")
                )
            else:
                # 如果是绝对路径，直接使用
                pdb_absolute_path = pdb_relative_path
            
            logger.info(f"PDB绝对路径: {pdb_absolute_path}")
            
            if not os.path.exists(pdb_absolute_path):
                logger.error(f"PDB文件不存在: {pdb_absolute_path}")
                raise HTTPException(status_code=404, detail=f"PDB文件不存在于服务器: {pdb_relative_path}")
            
            # 读取PDB文件内容
            with open(pdb_absolute_path, 'r') as f:
                pdb_content = f.read()
            
            logger.info(f"成功读取PDB文件，大小: {len(pdb_content)} 字节")
            return {"pdb_content": pdb_content}
    
    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"获取PDB文件失败: {str(e)}")
        raise HTTPException(status_code=500, detail=f"获取PDB文件失败: {str(e)}")


@app.get("/pdb/{protein_id}/download")
async def download_pdb_file(protein_id: int):
    """下载PDB文件（返回文件流，支持进度显示）"""
    try:
        with engine.begin() as conn:
            # 获取蛋白质信息和PDB文件路径
            result = conn.execute(
                text("SELECT accession, pdb_file FROM proteins WHERE id = :protein_id"),
                {"protein_id": protein_id}
            )
            row = result.fetchone()
            
            if not row:
                raise HTTPException(status_code=404, detail=f"蛋白质ID {protein_id} 不存在")
            
            accession = row[0] or f"protein_{protein_id}"
            pdb_relative_path = row[1]
            
            if not pdb_relative_path or pdb_relative_path == "":
                raise HTTPException(status_code=404, detail=f"该蛋白质没有3D结构文件")
            
            # 构建完整的PDB文件路径
            if pdb_relative_path.startswith("../"):
                pdb_absolute_path = os.path.join(
                    "/root/dev_folder/boid_database",
                    pdb_relative_path.replace("../", "")
                )
            else:
                pdb_absolute_path = pdb_relative_path
            
            if not os.path.exists(pdb_absolute_path):
                raise HTTPException(status_code=404, detail=f"PDB文件不存在于服务器")
            
            # 获取文件名
            filename = os.path.basename(pdb_absolute_path)
            if not filename.endswith('.pdb'):
                filename = f"{accession}.pdb"
            
            # 返回文件流
            return FileResponse(
                path=pdb_absolute_path,
                filename=filename,
                media_type='application/octet-stream',
                headers={
                    "Content-Disposition": f"attachment; filename={filename}"
                }
            )
    
    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"下载PDB文件失败: {str(e)}")
        raise HTTPException(status_code=500, detail=f"下载PDB文件失败: {str(e)}")


# ============= 身份验证相关API端点 =============

@app.post("/auth/send-code")
async def send_code():
    """发送验证码到管理员邮箱"""
    try:
        # 清理过期的验证码
        current_time = datetime.now()
        expired_codes = [code for code, data in verification_codes.items() 
                        if current_time > data["expires"]]
        for code in expired_codes:
            del verification_codes[code]
        
        # 生成新验证码
        code = generate_verification_code()
        verification_codes[code] = {
            "expires": datetime.now() + timedelta(minutes=VERIFICATION_CODE_EXPIRE_MINUTES),
            "used": False
        }
        
        # 根据 DEV_MODE 决定行为
        if DEV_MODE:
            # 开发模式：直接返回验证码，不发送邮件
            logger.warning(f"⚠️ 开发模式已启用，验证码: {code}")
            return {
                "success": True,
                "message": "开发模式：验证码已生成",
                "email": "开发模式",
                "code": code,
                "dev_mode": True
            }
        else:
            # 生产模式：必须成功发送邮件
            logger.info(f"🔒 生产模式：尝试发送验证码到 {ADMIN_EMAIL}")
            success = send_verification_email(code)
            
            if success:
                logger.info(f"✅ 验证码已成功发送到 {ADMIN_EMAIL}")
                return {
                    "success": True,
                    "message": f"验证码已发送到 {ADMIN_EMAIL}",
                    "email": ADMIN_EMAIL,
                    "dev_mode": False
                }
            else:
                # 生产模式下邮件发送失败，返回错误
                logger.error(f"❌ 生产模式：邮件发送失败")
                raise HTTPException(
                    status_code=500, 
                    detail="邮件发送失败，请检查邮箱配置或联系管理员"
                )
            
    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"发送验证码异常: {str(e)}")
        if DEV_MODE:
            # 开发模式：即使异常也返回验证码
            logger.warning(f"⚠️ 开发模式异常处理：返回验证码 {code}")
            return {
                "success": True,
                "message": "开发模式：验证码已生成（异常）",
                "email": "开发模式",
                "code": code,
                "dev_mode": True
            }
        else:
            # 生产模式：异常时返回错误
            raise HTTPException(status_code=500, detail=f"发送验证码失败: {str(e)}")

@app.post("/auth/verify-code")
async def verify_code(request: VerifyCodeRequest):
    """验证验证码并返回token"""
    try:
        code = request.code.strip()
        
        if code not in verification_codes:
            raise HTTPException(status_code=400, detail="验证码无效")
        
        code_data = verification_codes[code]
        
        if datetime.now() > code_data["expires"]:
            del verification_codes[code]
            raise HTTPException(status_code=400, detail="验证码已过期")
        
        if code_data["used"]:
            raise HTTPException(status_code=400, detail="验证码已使用")
        
        # 标记验证码已使用
        code_data["used"] = True
        
        # 生成token（有效期从配置读取）
        token = generate_token()
        active_tokens[token] = {
            "expires": datetime.now() + timedelta(hours=TOKEN_EXPIRE_HOURS)
        }
        
        logger.info(f"验证码验证成功，生成token（有效期: {TOKEN_EXPIRE_HOURS}小时）")
        
        return {
            "success": True,
            "token": token,
            "expires_in": TOKEN_EXPIRE_HOURS * 3600  # 转换为秒
        }
        
    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"验证失败: {str(e)}")
        raise HTTPException(status_code=500, detail=f"验证失败: {str(e)}")

@app.post("/auth/verify-token")
async def verify_token_endpoint(authorization: str = Header(None)):
    """验证token是否有效"""
    try:
        verify_token(authorization)
        return {"success": True, "message": "token有效"}
    except HTTPException as e:
        raise e

@app.post("/auth/logout")
async def logout(authorization: str = Header(None)):
    """登出（删除token）"""
    try:
        if authorization and authorization.startswith("Bearer "):
            token = authorization.replace("Bearer ", "")
            if token in active_tokens:
                del active_tokens[token]
        return {"success": True, "message": "登出成功"}
    except Exception as e:
        logger.error(f"登出失败: {str(e)}")
        return {"success": True, "message": "登出成功"}


# ============= BLAST序列搜索功能 =============

def clean_sequence(seq: str) -> str:
    """
    清理和验证氨基酸序列
    - 移除FASTA头部和空白字符
    - 验证氨基酸字符
    """
    # 移除FASTA头部(以>开头的行)
    lines = seq.strip().split('\n')
    clean = ''.join(line for line in lines if not line.startswith('>'))
    
    # 移除所有空白字符
    clean = ''.join(clean.split())
    
    # 验证氨基酸字符
    valid_aa = set('ACDEFGHIKLMNPQRSTVWY*X')
    invalid_chars = set(c.upper() for c in clean) - valid_aa
    
    if invalid_chars:
        raise HTTPException(
            status_code=400,
            detail=f"无效的氨基酸序列，包含非法字符: {', '.join(invalid_chars)}"
        )
    
    return clean.upper()


@app.post("/api/search/sequence/")
async def search_by_sequence(
    sequence: str = Form(...),
    evalue: float = Form(0.001),
    max_target_seqs: int = Form(100)
):
    """
    基于氨基酸序列的BLAST搜索
    
    参数:
    - sequence: 输入的氨基酸序列(支持FASTA格式或纯序列)
    - evalue: E-value阈值（默认0.001，越小越严格）
    - max_target_seqs: 最大返回结果数（默认100）
    
    返回:
    - success: 是否成功
    - message: 提示信息
    - query_length: 查询序列长度
    - total_hits: 匹配数量
    - results: 匹配结果列表
    - execution_time: 执行时间(秒)
    - cached: 是否来自缓存
    - logs: 操作日志
    """
    start_time = time.time()
    logs = []
    
    def add_log(msg: str, level: str = "info"):
        """添加日志条目"""
        log_entry = {
            "timestamp": datetime.now().isoformat(),
            "level": level,
            "message": msg
        }
        logs.append(log_entry)
        if level == "info":
            logger.info(msg)
        else:
            logger.error(msg)
    
    try:
        # 1. 输入验证
        add_log("开始序列搜索...")
        
        if not sequence or not sequence.strip():
            raise HTTPException(status_code=400, detail="序列不能为空")
        
        clean_seq = clean_sequence(sequence)
        
        if len(clean_seq) < 10:
            raise HTTPException(
                status_code=400,
                detail="序列太短，至少需要10个氨基酸"
            )
        
        if len(clean_seq) > MAX_SEQUENCE_LENGTH:
            raise HTTPException(
                status_code=400,
                detail=f"序列太长，最多允许{MAX_SEQUENCE_LENGTH}个氨基酸"
            )
        
        # 验证E-value范围
        if not (1e-10 <= evalue <= 10):
            raise HTTPException(
                status_code=400,
                detail="E-value必须在1e-10到10之间"
            )
        
        # 验证max_target_seqs范围
        if not (1 <= max_target_seqs <= MAX_TARGET_SEQS):
            raise HTTPException(
                status_code=400,
                detail=f"max_target_seqs必须在1到{MAX_TARGET_SEQS}之间"
            )
        
        add_log(f"查询序列: {len(clean_seq)} aa, E-value: {evalue}, 最大结果数: {max_target_seqs}")
        
        # 2. 检查缓存
        query_hash = hashlib.md5(
            f"{clean_seq}{evalue}{max_target_seqs}".encode()
        ).hexdigest()
        
        if query_hash in search_cache:
            cache_entry = search_cache[query_hash]
            if time.time() < cache_entry["expires"]:
                add_log("✓ 命中缓存")
                exec_time = time.time() - start_time
                
                return {
                    "success": True,
                    "message": f"找到 {len(cache_entry['result'])} 个匹配结果(缓存)",
                    "query_length": len(clean_seq),
                    "total_hits": len(cache_entry["result"]),
                    "results": cache_entry["result"],
                    "execution_time": exec_time,
                    "cached": True,
                    "logs": logs
                }
        
        # 3. 创建安全临时文件
        query_file = None
        result_file = None
        
        try:
            # 使用临时文件(自动清理)
            query_fd = tempfile.NamedTemporaryFile(
                mode='w',
                suffix='.fasta',
                delete=False,
                dir=TEMP_DIR
            )
            query_file = query_fd.name
            query_fd.write(f">query\n{clean_seq}\n")
            query_fd.close()
            
            result_fd = tempfile.NamedTemporaryFile(
                mode='w',
                suffix='.txt',
                delete=False,
                dir=TEMP_DIR
            )
            result_file = result_fd.name
            result_fd.close()
            
            # 4. 执行BLAST搜索(使用参数列表,防止命令注入)
            add_log("正在执行BLAST搜索...")
            
            blast_cmd = [
                'blastp',
                '-query', query_file,
                '-db', BLAST_DB_PATH,
                '-out', result_file,
                '-outfmt', '6 qseqid sseqid pident length mismatch gapopen qstart qend sstart send evalue bitscore',
                '-evalue', str(evalue),
                '-max_target_seqs', str(max_target_seqs),
                '-num_threads', str(min(os.cpu_count() or 1, 4))  # 最多4线程
            ]
            
            result = subprocess.run(
                blast_cmd,
                capture_output=True,
                text=True,
                timeout=BLAST_TIMEOUT
            )
            
            if result.returncode != 0:
                logger.error(f"BLAST错误: {result.stderr}")
                raise HTTPException(
                    status_code=500,
                    detail=f"BLAST搜索失败: {result.stderr}"
                )
            
            # 5. 解析BLAST结果
            blast_results = []
            with open(result_file, 'r') as f:
                for line in f:
                    fields = line.strip().split('\t')
                    if len(fields) >= 12:
                        blast_results.append({
                            'accession': fields[1].split()[0],
                            'identity': float(fields[2]),
                            'alignment_length': int(fields[3]),
                            'mismatches': int(fields[4]),
                            'gap_opens': int(fields[5]),
                            'query_start': int(fields[6]),
                            'query_end': int(fields[7]),
                            'subject_start': int(fields[8]),
                            'subject_end': int(fields[9]),
                            'evalue': float(fields[10]),
                            'bit_score': float(fields[11])
                        })
            
            add_log(f"✓ BLAST完成，找到 {len(blast_results)} 个匹配")
            
        finally:
            # 清理临时文件
            if query_file and os.path.exists(query_file):
                try:
                    os.unlink(query_file)
                except:
                    pass
            if result_file and os.path.exists(result_file):
                try:
                    os.unlink(result_file)
                except:
                    pass
        
        # 6. 如果没有结果
        if not blast_results:
            exec_time = time.time() - start_time
            add_log("未找到匹配结果")
            return {
                "success": True,
                "message": "未找到匹配的蛋白质序列",
                "query_length": len(clean_seq),
                "total_hits": 0,
                "results": [],
                "execution_time": exec_time,
                "cached": False,
                "logs": logs
            }
        
        # 7. 从数据库获取详细信息
        add_log("正在获取蛋白质详细信息...")
        accessions = [r['accession'] for r in blast_results]
        
        with engine.begin() as conn:
            # 构建IN查询
            placeholders = ','.join([f':acc{i}' for i in range(len(accessions))])
            query = f"""
                SELECT id, accession, species, type, assembly, the_neighbor_of,
                       LEFT(protein_sequence, 100) as seq_preview, pdb_file
                FROM proteins
                WHERE accession IN ({placeholders})
            """
            params = {f'acc{i}': acc for i, acc in enumerate(accessions)}
            rows = conn.execute(text(query), params).fetchall()
            
            protein_details = {}
            for row in rows:
                protein_details[row[1]] = {
                    'id': row[0],
                    'accession': row[1],
                    'species': row[2],
                    'type': row[3],
                    'assembly': row[4],
                    'the_neighbor_of': row[5],
                    'sequence_preview': (row[6] + '...') if row[6] else '',
                    'pdb_file': row[7],
                    'has_pdb': bool(row[7] and row[7] != "")
                }
        
        # 8. 合并BLAST结果和数据库详情
        final_results = []
        for blast_result in blast_results:
            acc = blast_result['accession']
            if acc in protein_details:
                final_results.append({
                    **protein_details[acc],
                    **blast_result
                })
        
        # 9. 缓存结果(1小时)
        search_cache[query_hash] = {
            "result": final_results,
            "expires": time.time() + 3600
        }
        
        exec_time = time.time() - start_time
        add_log(f"✓ 搜索完成，耗时 {exec_time:.2f}秒")
        
        return {
            "success": True,
            "message": f"找到 {len(final_results)} 个匹配结果",
            "query_length": len(clean_seq),
            "total_hits": len(final_results),
            "results": final_results,
            "execution_time": exec_time,
            "cached": False,
            "logs": logs
        }
    
    except subprocess.TimeoutExpired:
        add_log(f"❌ BLAST超时(>{BLAST_TIMEOUT}秒)", "error")
        raise HTTPException(
            status_code=504,
            detail=f"搜索超时，请缩短序列或增大E-value"
        )
    except HTTPException:
        raise
    except Exception as e:
        add_log(f"❌ 搜索失败: {str(e)}", "error")
        logger.error(f"序列搜索失败: {str(e)}", exc_info=True)
        raise HTTPException(
            status_code=500,
            detail=f"搜索失败: {str(e)}"
        )


# ============= Foldseek结构搜索功能 =============

@app.post("/api/search/structure/")
async def search_by_structure(
    query_accession: str = Form(None),
    pdb_file: UploadFile = File(None),
    tm_score_threshold: float = Form(0.5),
    max_results: int = Form(100)
):
    """
    基于蛋白质3D结构的Foldseek搜索
    
    支持两种输入方式:
    1. query_accession: 从数据库选择已有蛋白质的PDB文件
    2. pdb_file: 上传自定义PDB文件
    
    参数:
    - query_accession: 数据库中蛋白质的accession (可选)
    - pdb_file: 上传的PDB文件 (可选)
    - tm_score_threshold: TM-score阈值 (0-1, 默认0.5)
    - max_results: 最大返回结果数 (10-500, 默认100)
    
    返回:
    - success: 是否成功
    - query_identifier: 查询标识
    - total_hits: 匹配数量
    - results: 结果列表
    - execution_time: 执行时间(秒)
    - cached: 是否来自缓存
    - logs: 操作日志
    """
    start_time = time.time()
    logs = []
    query_pdb_path = None
    result_dir = None
    
    def add_log(message: str, level: str = "info"):
        timestamp = datetime.now().isoformat()
        logs.append({"timestamp": timestamp, "level": level, "message": message})
        logger.info(f"[Foldseek] {message}")
    
    try:
        add_log("开始Foldseek结构搜索")
        
        # 验证输入
        if not query_accession and not pdb_file:
            raise HTTPException(
                status_code=400,
                detail="必须提供query_accession或上传pdb_file其中之一"
            )
        
        if query_accession and pdb_file:
            raise HTTPException(
                status_code=400,
                detail="只能选择一种查询方式"
            )
        
        # 验证参数
        if not 0 <= tm_score_threshold <= 1:
            raise HTTPException(
                status_code=400,
                detail="TM-score阈值必须在0-1之间"
            )
        
        if not 10 <= max_results <= 500:
            raise HTTPException(
                status_code=400,
                detail="最大结果数必须在10-500之间"
            )
        
        # 确定查询PDB文件
        cache_key_base = None  # 用于缓存的稳定标识
        
        if query_accession:
            add_log(f"从数据库查询: {query_accession}")
            
            with engine.connect() as conn:
                result = conn.execute(
                    text("SELECT id, accession, species, type, pdb_file FROM proteins WHERE accession = :acc"),
                    {"acc": query_accession}
                )
                protein = result.fetchone()
            
            if protein:
                protein = {
                    'id': protein[0],
                    'accession': protein[1],
                    'species': protein[2],
                    'type': protein[3],
                    'pdb_file': protein[4]
                }
            
            if not protein:
                raise HTTPException(status_code=404, detail=f"未找到: {query_accession}")
            
            if not protein['pdb_file']:
                raise HTTPException(status_code=404, detail=f"{query_accession} 没有PDB文件")
            
            pdb_filename = os.path.basename(protein['pdb_file'])
            query_pdb_path = os.path.join(FOLDSEEK_PDB_DIR, pdb_filename)
            
            if not os.path.exists(query_pdb_path):
                raise HTTPException(status_code=404, detail=f"PDB文件不存在: {pdb_filename}")
            
            add_log(f"使用PDB: {pdb_filename}")
            query_identifier = query_accession
            
            # 缓存key: 使用accession (稳定)
            cache_key_base = f"accession:{query_accession}"
            
        else:
            add_log(f"处理上传: {pdb_file.filename}")
            
            if not pdb_file.filename.endswith('.pdb'):
                raise HTTPException(status_code=400, detail="只支持.pdb格式")
            
            content = await pdb_file.read()
            if len(content) > MAX_PDB_FILE_SIZE:
                raise HTTPException(status_code=413, detail="文件过大")
            
            with tempfile.NamedTemporaryFile(mode='wb', suffix='.pdb', delete=False, dir=TEMP_DIR) as f:
                f.write(content)
                query_pdb_path = f.name
            
            query_identifier = pdb_file.filename
            
            # 缓存key: 使用文件内容的MD5 (稳定)
            content_hash = hashlib.md5(content).hexdigest()
            cache_key_base = f"upload:{content_hash}"
        
        # 检查缓存 (使用稳定的标识符)
        query_hash = hashlib.md5(f"{cache_key_base}:{tm_score_threshold}:{max_results}".encode()).hexdigest()
        add_log(f"缓存key: {query_hash[:8]}... (base: {cache_key_base[:30]}...)")
        
        if query_hash in search_cache:
            cached_data = search_cache[query_hash]
            if cached_data["expires"] > time.time():
                add_log("✓ 从缓存返回")
                result = cached_data["result"].copy()  # 复制结果避免修改缓存
                result["cached"] = True
                result["execution_time"] = time.time() - start_time  # 更新为当前请求的时间
                result["logs"] = logs
                return result
        
        # 创建临时目录
        result_dir = tempfile.mkdtemp(dir=TEMP_DIR)
        result_prefix = os.path.join(result_dir, "result")
        
        add_log(f"执行Foldseek，TM阈值: {tm_score_threshold}")
        
        cmd = [
            "foldseek", "easy-search",
            query_pdb_path, FOLDSEEK_DB_PATH,
            result_prefix + ".tsv", result_dir,
            "--format-mode", "4",
            "--format-output", "query,target,alntmscore,prob,rmsd,qlen,tlen,alnlen",
            "-e", "inf",
            "--max-seqs", str(max_results * 2)
        ]
        
        result = subprocess.run(cmd, capture_output=True, text=True, timeout=FOLDSEEK_TIMEOUT)
        
        if result.returncode != 0:
            raise HTTPException(status_code=500, detail=f"Foldseek失败: {result.stderr[:200]}")
        
        add_log("✓ 执行完成")
        
        # 解析结果
        result_file = result_prefix + ".tsv"
        if not os.path.exists(result_file):
            return {
                "success": True,
                "message": "未找到匹配",
                "query_identifier": query_identifier,
                "total_hits": 0,
                "results": [],
                "execution_time": time.time() - start_time,
                "cached": False,
                "logs": logs
            }
        
        matches = []
        with open(result_file, 'r') as f:
            for line in f:
                if line.startswith('#'):
                    continue
                parts = line.strip().split('\t')
                if len(parts) < 8:
                    continue
                
                try:
                    tm_score = float(parts[2])
                    if tm_score < tm_score_threshold:
                        continue
                    
                    matches.append({
                        "target": parts[1],
                        "tm_score": tm_score,
                        "prob": float(parts[3]),
                        "rmsd": float(parts[4]),
                        "qlen": int(parts[5]),
                        "tlen": int(parts[6]),
                        "alnlen": int(parts[7])
                    })
                except (ValueError, IndexError):
                    continue
        
        matches.sort(key=lambda x: x['tm_score'], reverse=True)
        matches = matches[:max_results]
        
        add_log(f"找到 {len(matches)} 个匹配")
        
        # 查询数据库详情
        final_results = []
        if matches:
            with engine.connect() as conn:
                for m in matches:
                    target = m['target']
                    accession = None
                    
                    # 提取accession
                    if '|' in target:
                        for part in target.split('|'):
                            if '.' in part and any(x in part for x in ['WP_', 'BAP', 'SIT', 'MBB', 'HHX', 'EXA', 'NSL', 'KAI']):
                                accession = part
                                break
                    else:
                        if '_protein_' in target:
                            accession = target.split('_protein_')[1].split('_')[0]
                    
                    if not accession:
                        continue
                    
                    result = conn.execute(
                        text("SELECT id, accession, species, type, assembly, pdb_file IS NOT NULL as has_pdb FROM proteins WHERE accession = :acc"),
                        {"acc": accession}
                    )
                    
                    protein = result.fetchone()
                    if protein:
                        final_results.append({
                            "id": protein[0],
                            "accession": protein[1],
                            "species": protein[2],
                            "type": protein[3],
                            "assembly": protein[4],
                            "has_pdb": bool(protein[5]),
                            "tm_score": round(m['tm_score'], 4),
                            "probability": round(m['prob'], 4),
                            "rmsd": round(m['rmsd'], 2),
                            "alignment_length": m['alnlen'],
                            "target_length": m['tlen']
                        })
        
        add_log(f"✓ 返回 {len(final_results)} 个结果")
        
        result_data = {
            "success": True,
            "message": f"找到 {len(final_results)} 个匹配",
            "query_identifier": query_identifier,
            "total_hits": len(final_results),
            "results": final_results,
            "execution_time": time.time() - start_time,
            "cached": False
        }
        
        search_cache[query_hash] = {"result": result_data, "expires": time.time() + 3600}
        result_data["logs"] = logs
        return result_data
    
    except subprocess.TimeoutExpired:
        add_log(f"❌ 超时(>{FOLDSEEK_TIMEOUT}秒)", "error")
        raise HTTPException(status_code=504, detail="搜索超时")
    except HTTPException:
        raise
    except Exception as e:
        add_log(f"❌ 失败: {str(e)}", "error")
        logger.error(f"结构搜索失败: {str(e)}", exc_info=True)
        raise HTTPException(status_code=500, detail=f"搜索失败: {str(e)}")
    finally:
        if query_pdb_path and pdb_file and os.path.exists(query_pdb_path):
            try:
                os.unlink(query_pdb_path)
            except:
                pass
        if result_dir and os.path.exists(result_dir):
            try:
                import shutil
                shutil.rmtree(result_dir)
            except:
                pass


# ============================================================================
# arcCHAT 聊天API接口
# ============================================================================

# 初始化qwen-agent Assistant
agent_bot = init_agent_service()

# ============================================================================
# 工具函数（已移除，改用qwen-agent工具）
# 所有工具调用现在由qwen-agent自动处理
# ============================================================================

def generate_search_results_html(tool_results: list) -> str:
    """
    根据工具调用结果生成交互式HTML展示
    
    Args:
        tool_results: 工具调用结果列表
    
    Returns:
        HTML字符串
    """
    html_parts = []
    
    logger.info(f"生成搜索结果HTML，工具调用数量: {len(tool_results)}")
    
    for tool_call in tool_results:
        tool_name = tool_call.get("tool") or tool_call.get("name", "")
        result = tool_call.get("result", {})
        
        logger.info(f"处理工具: {tool_name}, 结果类型: {type(result)}")
        
        # 解析result（可能是JSON字符串）
        if isinstance(result, str):
            try:
                result = json.loads(result)
            except:
                logger.warning(f"无法解析result JSON字符串: {result[:200]}")
                continue
        
        if not result or result.get("success") == False:
            logger.warning(f"工具结果无效或失败: {result}")
            continue
        
        # 蛋白质搜索结果
        if tool_name == "protein_search" or tool_name == "search_protein":
            results = result.get("results", [])
            total = result.get("total", len(results))
            
            logger.info(f"蛋白质搜索结果: 总数={total}, 结果数量={len(results)}")
            
            if total == 0:
                continue
            
            html = f"""
            <div class="search-results-container" style="margin-top: 16px; border: 1px solid #e8e8e8; border-radius: 8px; overflow: hidden;">
                <div class="search-results-header" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 12px 16px; display: flex; align-items: center; justify-content: space-between;">
                    <div style="display: flex; align-items: center; gap: 8px;">
                        <span style="font-size: 18px;">🔍</span>
                        <span style="font-weight: 600; font-size: 14px;">蛋白质搜索结果</span>
                    </div>
                    <span style="background: rgba(255,255,255,0.2); padding: 4px 12px; border-radius: 12px; font-size: 12px;">
                        共 {total} 条结果
                    </span>
                </div>
                <div class="search-results-content" style="max-height: 500px; overflow-y: auto; background: #fafafa;">
                    <table style="width: 100%; border-collapse: collapse;">
                        <thead style="background: #f0f0f0; position: sticky; top: 0;">
                            <tr>
                                <th style="padding: 10px; text-align: left; font-size: 12px; font-weight: 600; color: #666; border-bottom: 2px solid #e8e8e8;">登录号</th>
                                <th style="padding: 10px; text-align: left; font-size: 12px; font-weight: 600; color: #666; border-bottom: 2px solid #e8e8e8;">物种</th>
                                <th style="padding: 10px; text-align: left; font-size: 12px; font-weight: 600; color: #666; border-bottom: 2px solid #e8e8e8;">类型</th>
                                <th style="padding: 10px; text-align: left; font-size: 12px; font-weight: 600; color: #666; border-bottom: 2px solid #e8e8e8;">结构</th>
                                <th style="padding: 10px; text-align: center; font-size: 12px; font-weight: 600; color: #666; border-bottom: 2px solid #e8e8e8;">操作</th>
                            </tr>
                        </thead>
                        <tbody>
            """
            
            for idx, item in enumerate(results[:20]):  # 最多显示20条
                accession = item.get("accession", "")
                species = item.get("species", "")
                protein_type = item.get("type", "")
                has_pdb = item.get("has_pdb", False)
                protein_id = item.get("id")  # 可能是None或空字符串
                
                # 调试日志
                if idx < 3:  # 只记录前3条
                    logger.info(f"结果项 {idx}: accession={accession}, has_pdb={has_pdb}, protein_id={protein_id}")
                
                # 确保protein_id有效
                if protein_id:
                    protein_id = str(protein_id)
                else:
                    protein_id = None
                
                row_color = "#fff" if idx % 2 == 0 else "#f9f9f9"
                button_html = ""
                if has_pdb and protein_id:
                    button_html = f'<button class="view-3d-structure-btn" data-protein-id="{protein_id}" data-accession="{accession}" style="background: #52c41a; color: white; border: none; padding: 4px 12px; border-radius: 4px; cursor: pointer; font-size: 12px; margin-left: 4px;">查看3D结构</button>'
                    logger.info(f"为 {accession} 生成3D结构按钮，protein_id={protein_id}")
                else:
                    logger.info(f"跳过生成按钮: {accession}, has_pdb={has_pdb}, protein_id={protein_id}")
                
                html += f"""
                            <tr style="background: {row_color}; border-bottom: 1px solid #f0f0f0;" onmouseover="this.style.background='#f0f7ff'" onmouseout="this.style.background='{row_color}'">
                                <td style="padding: 10px; font-size: 13px; color: #2c3e50; font-family: monospace;">{accession}</td>
                                <td style="padding: 10px; font-size: 13px; color: #2c3e50;">{species}</td>
                                <td style="padding: 10px; font-size: 13px; color: #2c3e50;">{protein_type or '-'}</td>
                                <td style="padding: 10px; text-align: center;">
                                    {'<span style="color: #52c41a;">✓</span>' if has_pdb else '<span style="color: #999;">-</span>'}
                                </td>
                                <td style="padding: 10px; text-align: center;">
                                    <div style="display: flex; gap: 8px; justify-content: center; align-items: center;">
                                        {'<a href="/protein/' + str(protein_id) + '" target="_blank" style="color: #409eff; text-decoration: none; font-size: 12px;">查看详情</a>' if protein_id else '<span style="color: #999; font-size: 12px;">-</span>'}
                                        {button_html}
                                    </div>
                                </td>
                            </tr>
                """
            
            html += """
                        </tbody>
                    </table>
                </div>
            </div>
            """
            html_parts.append(html)
        
        # BLAST搜索结果
        elif tool_name == "blast_sequence_search":
            results = result.get("results", [])
            total_hits = result.get("total_hits", len(results))
            
            if total_hits == 0:
                continue
            
            html = f"""
            <div class="search-results-container" style="margin-top: 16px; border: 1px solid #e8e8e8; border-radius: 8px; overflow: hidden;">
                <div class="search-results-header" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); color: white; padding: 12px 16px; display: flex; align-items: center; justify-content: space-between;">
                    <div style="display: flex; align-items: center; gap: 8px;">
                        <span style="font-size: 18px;">🧬</span>
                        <span style="font-weight: 600; font-size: 14px;">BLAST序列比对结果</span>
                    </div>
                    <span style="background: rgba(255,255,255,0.2); padding: 4px 12px; border-radius: 12px; font-size: 12px;">
                        共 {total_hits} 个匹配
                    </span>
                </div>
                <div class="search-results-content" style="max-height: 500px; overflow-y: auto; background: #fafafa;">
            """
            
            for idx, item in enumerate(results[:10]):  # 最多显示10条
                accession = item.get("accession", "")
                species = item.get("species", "")
                e_value = item.get("e_value", "")
                identity = item.get("identity", "")
                protein_id = item.get("id")
                has_pdb = item.get("has_pdb", False)
                
                # 确保protein_id有效
                if protein_id:
                    protein_id = str(protein_id)
                else:
                    protein_id = None
                
                button_html = ""
                if has_pdb and protein_id:
                    button_html = f'<button class="view-3d-structure-btn" data-protein-id="{protein_id}" data-accession="{accession}" style="background: #52c41a; color: white; border: none; padding: 4px 12px; border-radius: 4px; cursor: pointer; font-size: 12px;">查看3D结构</button>'
                
                html += f"""
                    <div style="padding: 12px; border-bottom: 1px solid #e8e8e8; background: {'#fff' if idx % 2 == 0 else '#f9f9f9'};">
                        <div style="display: flex; justify-content: space-between; align-items: start;">
                            <div style="flex: 1;">
                                <div style="font-weight: 600; color: #2c3e50; margin-bottom: 6px;">
                                    <span style="font-family: monospace; color: #409eff;">{accession}</span>
                                    <span style="margin-left: 8px; font-size: 12px; color: #666;">{species}</span>
                                </div>
                                <div style="display: flex; gap: 16px; font-size: 12px; color: #666;">
                                    <span>E-value: <strong style="color: #2c3e50;">{e_value}</strong></span>
                                    <span>相似度: <strong style="color: #2c3e50;">{identity}%</strong></span>
                                </div>
                            </div>
                            <div style="display: flex; gap: 8px; align-items: center;">
                                {'<a href="/protein/' + str(protein_id) + '" target="_blank" style="color: #409eff; text-decoration: none; font-size: 12px; padding: 4px 12px; border: 1px solid #409eff; border-radius: 4px;">查看详情</a>' if protein_id else ''}
                                {button_html}
                            </div>
                        </div>
                    </div>
                """
            
            html += """
                </div>
            </div>
            """
            html_parts.append(html)
        
        # Foldseek搜索结果
        elif tool_name == "foldseek_structure_search":
            results = result.get("results", [])
            total_hits = result.get("total_hits", len(results))
            
            logger.info(f"Foldseek搜索结果: 总数={total_hits}, 结果数量={len(results)}")
            
            if total_hits == 0:
                continue
            
            # 生成HTML前，先记录一下第一个结果的完整结构
            if results:
                logger.info(f"Foldseek第一个结果示例: {json.dumps(results[0], ensure_ascii=False, indent=2)[:500]}")
            
            html = f"""
            <div class="search-results-container" style="margin-top: 16px; border: 1px solid #e8e8e8; border-radius: 8px; overflow: hidden;">
                <div class="search-results-header" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); color: white; padding: 12px 16px; display: flex; align-items: center; justify-content: space-between;">
                    <div style="display: flex; align-items: center; gap: 8px;">
                        <span style="font-size: 18px;">⚛️</span>
                        <span style="font-weight: 600; font-size: 14px;">Foldseek结构比对结果</span>
                    </div>
                    <span style="background: rgba(255,255,255,0.2); padding: 4px 12px; border-radius: 12px; font-size: 12px;">
                        共 {total_hits} 个匹配
                    </span>
                </div>
                <div class="search-results-content" style="max-height: 500px; overflow-y: auto; background: #fafafa;">
            """
            
            for idx, item in enumerate(results[:10]):  # 最多显示10条
                accession = item.get("accession", "")
                species = item.get("species", "")
                tm_score = item.get("tm_score", "")
                probability = item.get("probability", "")
                protein_id = item.get("id")
                has_pdb = item.get("has_pdb", False)
                
                # 调试日志
                if idx < 3:
                    logger.debug(f"Foldseek结果项 {idx}: accession={accession}, has_pdb={has_pdb}, protein_id={protein_id}")
                
                # 确保protein_id有效
                if protein_id:
                    protein_id = str(protein_id)
                else:
                    protein_id = None
                
                button_html = ""
                if has_pdb and protein_id:
                    button_html = f'<button class="view-3d-structure-btn" data-protein-id="{protein_id}" data-accession="{accession}" style="background: #52c41a; color: white; border: none; padding: 4px 12px; border-radius: 4px; cursor: pointer; font-size: 12px;">查看3D结构</button>'
                    logger.debug(f"为Foldseek结果 {accession} 生成3D结构按钮，protein_id={protein_id}")
                else:
                    logger.debug(f"跳过Foldseek按钮: {accession}, has_pdb={has_pdb}, protein_id={protein_id}")
                
                html += f"""
                    <div style="padding: 12px; border-bottom: 1px solid #e8e8e8; background: {'#fff' if idx % 2 == 0 else '#f9f9f9'};">
                        <div style="display: flex; justify-content: space-between; align-items: start;">
                            <div style="flex: 1;">
                                <div style="font-weight: 600; color: #2c3e50; margin-bottom: 6px;">
                                    <span style="font-family: monospace; color: #409eff;">{accession}</span>
                                    <span style="margin-left: 8px; font-size: 12px; color: #666;">{species}</span>
                                </div>
                                <div style="display: flex; gap: 16px; font-size: 12px; color: #666;">
                                    <span>TM-score: <strong style="color: #2c3e50;">{tm_score}</strong></span>
                                    <span>概率: <strong style="color: #2c3e50;">{probability}</strong></span>
                                </div>
                            </div>
                            <div style="display: flex; gap: 8px; align-items: center;">
                                {'<a href="/protein/' + str(protein_id) + '" target="_blank" style="color: #409eff; text-decoration: none; font-size: 12px; padding: 4px 12px; border: 1px solid #409eff; border-radius: 4px;">查看详情</a>' if protein_id else ''}
                                {button_html}
                            </div>
                        </div>
                    </div>
                """
            
            html += """
                </div>
            </div>
            """
            html_parts.append(html)
            
            # 记录生成的HTML中是否包含按钮
            button_count = html.count('view-3d-structure-btn')
            logger.info(f"Foldseek HTML生成完成，包含 {button_count} 个3D结构按钮")
    
    final_html = "\n".join(html_parts) if html_parts else ""
    
    # 最终检查
    if final_html:
        total_buttons = final_html.count('view-3d-structure-btn')
        logger.info(f"最终HTML生成完成，总共包含 {total_buttons} 个3D结构按钮")
    
    return final_html

# Pydantic模型
class ChatSendRequest(BaseModel):
    session_id: Optional[str] = None
    message: str
    stream: bool = False
    client_session_id: Optional[str] = None  # 客户端会话ID（用于会话隔离）

class ChatMessage(BaseModel):
    role: str
    content: str

@app.post("/api/chat/send")
async def send_chat_message(request: ChatSendRequest):
    """
    发送聊天消息
    
    功能:
    - 创建或获取会话
    - 保存用户消息
    - 调用LLM生成回复
    - 保存助手回复
    - 返回结果
    """
    try:
        # 验证 client_session_id
        if not request.client_session_id:
            raise HTTPException(status_code=400, detail="缺少客户端会话ID（client_session_id）")
        
        # 1. 创建或获取会话
        session_id = request.session_id
        if not session_id:
            session_id = str(uuid.uuid4())
            # 创建新会话时关联 client_session_id
            with engine.begin() as conn:
                conn.execute(
                    text("""
                        INSERT INTO chat_sessions (session_id, client_session_id, title)
                        VALUES (:session_id, :client_session_id, :title)
                    """),
                    {
                        "session_id": session_id,
                        "client_session_id": request.client_session_id,
                        "title": request.message[:50] if len(request.message) <= 50 else request.message[:47] + "..."
                    }
                )
            logger.info(f"创建新会话: {session_id}, client_session_id: {request.client_session_id}")
        else:
            # 验证会话是否属于当前客户端
            with engine.connect() as conn:
                result = conn.execute(
                    text("""
                        SELECT client_session_id FROM chat_sessions
                        WHERE session_id = :session_id
                    """),
                    {"session_id": session_id}
                )
                session_row = result.fetchone()
                if not session_row:
                    raise HTTPException(status_code=404, detail="会话不存在")
                
                stored_client_session_id = session_row[0]
                if stored_client_session_id != request.client_session_id:
                    raise HTTPException(status_code=403, detail="无权访问此会话")
        
        # 2. 保存用户消息
        with engine.begin() as conn:
            conn.execute(
                text("""
                    INSERT INTO chat_messages (session_id, role, content)
                    VALUES (:session_id, 'user', :content)
                """),
                {"session_id": session_id, "content": request.message}
            )
        
        # 3. 获取历史消息(最近10条，不包括刚保存的当前用户消息)
        # 注意：我们已经在步骤2保存了当前用户消息，所以这里获取的是之前的历史消息
        with engine.connect() as conn:
            result = conn.execute(
                text("""
                    SELECT role, content 
                    FROM chat_messages 
                    WHERE session_id = :session_id 
                    ORDER BY created_at DESC 
                    LIMIT 11
                """),
                {"session_id": session_id}
            )
            rows = result.fetchall()
            
            # 构建历史消息列表（排除刚保存的当前用户消息）
            history = []
            for row in rows:
                msg_role, msg_content = row[0], row[1]
                # 如果最后一条是刚保存的user消息，跳过它（因为我们要在最后添加）
                if not history and msg_role == "user" and msg_content == request.message:
                    continue
                history.append({"role": msg_role, "content": msg_content})
            
            history.reverse()  # 按时间正序
        
        # 4. 使用qwen-agent处理对话
        logger.info(f"使用qwen-agent处理对话,会话: {session_id}")
        
        # 构建消息列表（qwen-agent格式）
        # qwen-agent要求消息列表必须以user消息开始（不包括system消息）
        # 如果历史消息以assistant开头，需要移除开头的assistant消息
        
        # 确保历史消息格式正确：移除开头的assistant消息（如果有）
        while history and history[0].get("role") != "user":
            logger.warning(f"移除历史消息开头的非user消息: {history[0].get('role')}")
            history.pop(0)
        
        # 如果历史消息为空，确保至少有一条user消息
        messages = history.copy() if history else []
        
        # 添加当前用户消息（如果最后一条不是user消息，则添加）
        if not messages or messages[-1].get("role") != "user":
            messages.append({"role": "user", "content": request.message})
        else:
            # 如果最后一条已经是user消息（可能是刚保存的），更新内容
            messages[-1] = {"role": "user", "content": request.message}
        
        # 验证消息格式：确保第一条是user消息
        if not messages or messages[0].get("role") != "user":
            logger.error(f"消息格式错误：第一条消息role为 {messages[0].get('role') if messages else 'None'}")
            raise HTTPException(
                status_code=400,
                detail=f"消息格式错误：消息列表必须以user消息开始"
            )
        
        # 调试日志
        logger.info(f"构建的消息列表长度: {len(messages)}")
        if messages:
            logger.debug(f"第一条消息role: {messages[0].get('role')}, 最后一条消息role: {messages[-1].get('role')}")
        
        # 5. 调用qwen-agent生成回复（自动处理工具调用）
        assistant_responses = []
        tool_results = []
        
        try:
            # qwen-agent会自动处理工具调用
            # run()方法返回一个生成器，yield消息列表
            # 注意：BLAST和Foldseek搜索可能需要较长时间，所以这里使用较长的超时
            # 由于qwen-agent的run()是同步的，需要在线程池中运行以避免阻塞
            import asyncio
            from concurrent.futures import ThreadPoolExecutor
            
            logger.info(f"开始调用qwen-agent处理消息，消息长度: {len(request.message)}")
            
            # 在线程池中运行qwen-agent（避免阻塞事件循环）
            loop = asyncio.get_event_loop()
            with ThreadPoolExecutor(max_workers=1) as executor:
                # 同步执行qwen-agent的run方法
                def run_agent():
                    responses = []
                    try:
                        for response in agent_bot.run(messages=messages):
                            responses.append(response)
                            # 记录进度（避免长时间无响应）
                            if len(responses) % 10 == 0:
                                logger.info(f"qwen-agent处理中，已收到 {len(responses)} 个响应...")
                    except Exception as e:
                        logger.error(f"qwen-agent执行错误: {str(e)}", exc_info=True)
                        raise
                    return responses
                
                # 异步执行，设置超时（5分钟）
                assistant_responses = await asyncio.wait_for(
                    loop.run_in_executor(executor, run_agent),
                    timeout=300.0  # 5分钟超时
                )
            
            logger.info(f"qwen-agent处理完成，共收到 {len(assistant_responses)} 个响应")
            
            # qwen-agent返回的是消息列表，提取最后一条assistant消息和工具调用结果
            assistant_message = ""
            intent = "general_chat"
            
            # 从qwen-agent响应中提取工具调用结果
            # qwen-agent的工具调用结果可能在消息列表中，需要解析JSON字符串
            tool_results_from_agent = []
            
            # 先检查所有响应中的tool_calls字段
            logger.info(f"检查所有 {len(assistant_responses)} 个响应以提取工具调用结果")
            for resp_idx, response in enumerate(assistant_responses):
                if isinstance(response, list):
                    for msg_idx, msg in enumerate(response):
                        if isinstance(msg, dict) and "tool_calls" in msg:
                            tool_calls = msg.get("tool_calls", [])
                            logger.info(f"响应 {resp_idx} 消息 {msg_idx} 中有 {len(tool_calls)} 个tool_calls")
                            for tool_call in tool_calls:
                                if isinstance(tool_call, dict):
                                    tool_name = tool_call.get("name") or tool_call.get("function", {}).get("name") or tool_call.get("function_name")
                                    tool_result = tool_call.get("result") or tool_call.get("result_json") or tool_call.get("result_content")
                                    
                                    if tool_result:
                                        logger.info(f"从tool_calls中提取工具结果: {tool_name}")
                                        if isinstance(tool_result, str):
                                            try:
                                                tool_result = json.loads(tool_result)
                                            except:
                                                logger.debug(f"工具结果不是JSON格式: {tool_result[:100]}")
                                                continue
                                        
                                        if isinstance(tool_result, dict) and "success" in tool_result:
                                            tool_results_from_agent.append({
                                                "tool": tool_name or "unknown",
                                                "result": tool_result
                                            })
                                            logger.info(f"成功添加工具结果: {tool_name}")
            
            if assistant_responses:
                # 获取最后一条响应（通常是完整的消息列表）
                last_response = assistant_responses[-1]
                
                # 调试：记录响应结构
                logger.info(f"最后一条响应类型: {type(last_response)}")
                if isinstance(last_response, list):
                    logger.info(f"响应是列表，长度: {len(last_response)}")
                    for idx, msg in enumerate(last_response):
                        logger.debug(f"消息 {idx}: {type(msg)}, 键: {msg.keys() if isinstance(msg, dict) else 'N/A'}")
                        if isinstance(msg, dict):
                            logger.debug(f"消息 {idx} 内容: {str(msg)[:200]}")
                elif isinstance(last_response, dict):
                    logger.info(f"响应是字典，键: {last_response.keys()}")
                
                # 收集所有消息内容用于提取工具调用结果
                all_content_parts = []
                
                # qwen-agent可能返回消息列表或字符串
                if isinstance(last_response, list):
                    # 如果是消息列表，查找最后一条assistant消息和工具调用结果
                    for msg in reversed(last_response):
                        if isinstance(msg, dict):
                            # 检查是否有tool_calls字段（qwen-agent可能在这里存储工具调用结果）
                            if "tool_calls" in msg:
                                logger.info(f"发现tool_calls字段: {msg.get('tool_calls')}")
                                # 尝试从tool_calls中提取结果
                                tool_calls = msg.get("tool_calls", [])
                                for tool_call in tool_calls:
                                    if isinstance(tool_call, dict):
                                        tool_name = tool_call.get("name") or tool_call.get("function", {}).get("name")
                                        tool_result = tool_call.get("result") or tool_call.get("result_json")
                                        if tool_result:
                                            logger.info(f"从tool_calls中提取工具结果: {tool_name}")
                                            # 如果是字符串，尝试解析JSON
                                            if isinstance(tool_result, str):
                                                try:
                                                    tool_result = json.loads(tool_result)
                                                except:
                                                    pass
                                            tool_results_from_agent.append({
                                                "tool": tool_name,
                                                "result": tool_result
                                            })
                            
                            # 提取assistant消息内容
                            if msg.get("role") == "assistant" and not assistant_message:
                                assistant_message = msg.get("content", "")
                            
                            # 收集所有消息内容用于提取工具调用结果
                            if "content" in msg:
                                all_content_parts.append(msg.get("content", ""))
                        elif isinstance(msg, str):
                            all_content_parts.append(msg)
                        
                        if assistant_message:
                            break
                elif isinstance(last_response, dict):
                    # 如果是字典，尝试提取content
                    assistant_message = last_response.get("content", "")
                    if not assistant_message and "role" in last_response:
                        assistant_message = str(last_response)
                    all_content_parts.append(assistant_message)
                elif isinstance(last_response, str):
                    assistant_message = last_response
                    all_content_parts.append(assistant_message)
                
                # 如果没有找到assistant消息，尝试从所有响应中提取
                if not assistant_message:
                    for resp in reversed(assistant_responses):
                        if isinstance(resp, str) and resp.strip():
                            assistant_message = resp
                            all_content_parts.append(resp)
                            break
                        elif isinstance(resp, dict):
                            content = resp.get("content", "")
                            if content:
                                assistant_message = content
                                all_content_parts.append(content)
                                break
                
                # 从所有消息内容中提取工具调用结果
                # qwen-agent的工具返回的是JSON字符串，包含在消息内容中
                combined_content = "\n".join(all_content_parts)
                
                logger.info(f"合并后的内容长度: {len(combined_content)}, 前500字符: {combined_content[:500]}")
                
                # 使用更精确的方法提取JSON格式的工具调用结果
                # 匹配完整的JSON对象（支持嵌套）
                depth = 0
                start = -1
                json_candidates = []
                
                for i, char in enumerate(combined_content):
                    if char == '{':
                        if depth == 0:
                            start = i
                        depth += 1
                    elif char == '}':
                        depth -= 1
                        if depth == 0 and start != -1:
                            json_candidate = combined_content[start:i+1]
                            try:
                                parsed = json.loads(json_candidate)
                                if isinstance(parsed, dict) and "success" in parsed and ("results" in parsed or "total" in parsed):
                                    json_candidates.append(json_candidate)
                                    logger.info(f"找到JSON候选: {list(parsed.keys())[:5]}")
                            except:
                                pass
                            start = -1
                
                logger.info(f"找到 {len(json_candidates)} 个JSON候选")
                
                # 解析找到的JSON结果
                for json_str in json_candidates:
                    try:
                        parsed_result = json.loads(json_str)
                        # 判断是哪种工具的结果
                        if "results" in parsed_result and "total" in parsed_result:
                            # 蛋白质搜索结果
                            if not any(t.get("tool") == "protein_search" for t in tool_results_from_agent):
                                tool_results_from_agent.append({
                                    "tool": "protein_search",
                                    "result": parsed_result
                                })
                        elif "results" in parsed_result and "total_hits" in parsed_result:
                            # 可能是BLAST或Foldseek结果
                            first_result = parsed_result.get("results", [{}])[0] if parsed_result.get("results") else {}
                            if "e_value" in str(first_result) or "identity" in str(first_result):
                                if not any(t.get("tool") == "blast_sequence_search" for t in tool_results_from_agent):
                                    tool_results_from_agent.append({
                                        "tool": "blast_sequence_search",
                                        "result": parsed_result
                                    })
                            elif "tm_score" in str(first_result) or "probability" in str(first_result):
                                if not any(t.get("tool") == "foldseek_structure_search" for t in tool_results_from_agent):
                                    tool_results_from_agent.append({
                                        "tool": "foldseek_structure_search",
                                        "result": parsed_result
                                    })
                    except Exception as e:
                        logger.debug(f"解析工具调用结果JSON失败: {e}")
                        pass
            
            # 如果没有提取到内容，使用默认提示
            if not assistant_message:
                assistant_message = "抱歉，我无法生成回复。可能是处理时间过长或出现了错误。请检查日志或稍后重试。"
                logger.error("未能从qwen-agent响应中提取到内容")
            
            # 生成HTML结果（从已提取的工具调用结果）
            html_results = ""
            if tool_results_from_agent:
                html_results = generate_search_results_html(tool_results_from_agent)
                if html_results:
                    # 将HTML结果追加到assistant消息中
                    assistant_message += "\n\n" + html_results
                    logger.info(f"已生成 {len(tool_results_from_agent)} 个工具调用的HTML结果")
            
            # 无论是否已有工具调用结果，都检查用户消息中是否包含蛋白质accession
            # 如果用户明确提到查看某个蛋白质的3D结构，主动搜索
            import re
            # 匹配常见的accession格式：MBT、WP_、SIT、BAP等开头的accession号（支持点号）
            accession_pattern = r'\b(MBT\d+\.?\d*|WP_\d+\.?\d*|SIT\d+\.?\d*|BAP\d+\.?\d*|HHX\d+\.?\d*|EXA\d+\.?\d*|NSL\d+\.?\d*|KAI\d+\.?\d*)\b'
            accession_matches = re.findall(accession_pattern, request.message, re.IGNORECASE)
            
            logger.info(f"检查用户消息中的accession: {request.message}")
            logger.info(f"匹配到的accession: {accession_matches}")
            logger.info(f"是否包含3d或结构: {'3d' in request.message.lower() or '3D' in request.message or '结构' in request.message}")
            logger.info(f"当前html_results状态: {len(html_results) if html_results else 0} 字符")
            
            # 如果用户明确提到查看3D结构，无论是否有HTML结果，都主动搜索并确保显示按钮
            if accession_matches and ("3d" in request.message.lower() or "3D" in request.message or "结构" in request.message):
                logger.info(f"检测到用户请求查看3D结构，accession: {accession_matches}")
                
                # 检查当前HTML结果中是否已经包含这个accession的按钮
                accession_found_in_html = False
                if html_results:
                    for acc in accession_matches:
                        if acc in html_results:
                            # 检查是否已经有按钮
                            if f'data-accession="{acc}"' in html_results or f"data-accession='{acc}'" in html_results:
                                accession_found_in_html = True
                                logger.info(f"在现有HTML中找到了 {acc} 的按钮")
                                break
                
                # 如果没有找到按钮，主动搜索
                if not accession_found_in_html:
                    logger.info(f"未在HTML中找到按钮，主动搜索: {accession_matches}")
                    # 主动搜索这些accession
                    for accession in accession_matches[:1]:  # 只搜索第一个
                        try:
                            logger.info(f"开始搜索蛋白质: {accession}")
                            # 直接调用搜索API - 使用精确匹配
                            with engine.connect() as conn:
                                # 先尝试精确匹配
                                result = conn.execute(
                                    text("""
                                        SELECT id, accession, species, type, assembly, 
                                               LEFT(protein_sequence, 100) as protein_sequence_preview,
                                               pdb_file, created_at
                                        FROM proteins 
                                        WHERE accession = :acc
                                        LIMIT 1
                                    """),
                                    {"acc": accession}
                                )
                                row = result.fetchone()
                                
                                # 如果精确匹配失败，尝试模糊匹配
                                if not row:
                                    logger.info(f"精确匹配失败，尝试模糊匹配")
                                    result = conn.execute(
                                        text("""
                                            SELECT id, accession, species, type, assembly, 
                                                   LEFT(protein_sequence, 100) as protein_sequence_preview,
                                                   pdb_file, created_at
                                            FROM proteins 
                                            WHERE accession LIKE :acc
                                            LIMIT 1
                                        """),
                                        {"acc": f"%{accession}%"}
                                    )
                                    row = result.fetchone()
                                
                                if row:
                                    protein_data = {
                                        "id": row[0],
                                        "accession": row[1],
                                        "species": row[2],
                                        "type": row[3],
                                        "assembly": row[4],
                                        "protein_sequence_preview": (row[5] + "...") if row[5] else "",
                                        "pdb_file": row[6],
                                        "has_pdb": bool(row[6] and row[6] != ""),
                                        "created_at": str(row[7])
                                    }
                                    
                                    logger.info(f"找到蛋白质: {protein_data['accession']}, has_pdb: {protein_data['has_pdb']}, id: {protein_data['id']}")
                                    
                                    # 生成搜索结果
                                    search_result = {
                                        "success": True,
                                        "total": 1,
                                        "page": 1,
                                        "page_size": 1,
                                        "total_pages": 1,
                                        "query": accession,
                                        "results": [protein_data]
                                    }
                                    
                                    # 如果已有工具结果，检查是否已包含这个蛋白质
                                    protein_already_included = False
                                    if tool_results_from_agent:
                                        for tool_result in tool_results_from_agent:
                                            if tool_result.get("tool") == "protein_search":
                                                results = tool_result.get("result", {}).get("results", [])
                                                if any(r.get("accession") == accession for r in results):
                                                    protein_already_included = True
                                                    logger.info(f"蛋白质 {accession} 已在工具结果中")
                                                    break
                                    
                                    if not protein_already_included:
                                        tool_results_from_agent.append({
                                            "tool": "protein_search",
                                            "result": search_result
                                        })
                                    
                                    logger.info(f"主动搜索到蛋白质: {accession}, has_pdb: {protein_data['has_pdb']}")
                                    
                                    # 重新生成HTML（包含新搜索的蛋白质）
                                    html_results = generate_search_results_html(tool_results_from_agent)
                                    logger.info(f"重新生成的HTML长度: {len(html_results) if html_results else 0}")
                                    if html_results:
                                        # 如果之前已有HTML，替换它；否则追加
                                        if html_results in assistant_message:
                                            # HTML已在消息中，无需重复添加
                                            pass
                                        else:
                                            # 移除旧的HTML（如果有），添加新的
                                            assistant_message = re.sub(r'<div class="search-results-container"[\s\S]*?</div>\s*</div>', '', assistant_message)
                                            assistant_message += "\n\n" + html_results
                                        logger.info(f"已为 {accession} 生成HTML结果，HTML中包含按钮数: {html_results.count('view-3d-structure-btn')}")
                                    else:
                                        logger.warning(f"HTML生成失败或为空")
                                    break
                                else:
                                    logger.warning(f"未找到蛋白质: {accession}")
                        except Exception as e:
                            logger.error(f"主动搜索蛋白质失败: {e}", exc_info=True)
            
            # 更新tool_results变量（用于数据库存储）
            tool_results = tool_results_from_agent
            
            # 清理assistant消息中的JSON格式工具调用信息（在生成HTML之后）
            # 先保存HTML部分，避免被误删
            html_marker = "<<<HTML_RESULTS_MARKER>>>"
            if html_results and html_results in assistant_message:
                assistant_message = assistant_message.replace(html_results, html_marker)
            
            # 移除类似 {"name": "protein_search", "arguments": {...}} 的内容
            json_pattern = r'\{\s*"name"\s*:\s*"[^"]+"\s*,\s*"arguments"\s*:\s*\{[^}]*\}\s*\}|\{\s*"name"\s*:\s*"[^"]+"\s*,\s*"arguments"\s*:\s*\{[^{}]*\{[^}]*\}[^}]*\}\s*\}'
            assistant_message = re.sub(json_pattern, '', assistant_message, flags=re.IGNORECASE | re.DOTALL)
            
            # 移除JSON格式的工具调用结果（我们已经提取并转换为HTML了）
            # 移除类似 {"success": true, "results": [...]} 的JSON字符串
            # 注意：不使用lookbehind，因为Python不支持可变宽度的lookbehind
            # 使用函数来检查并移除不在HTML标签内的JSON字符串
            json_result_pattern = r'\{\s*"success"\s*:\s*true[^{}]*"results"\s*:\s*\[[^\]]+\][^}]*\}'
            
            def remove_json_results(text):
                """
                移除不在HTML标签内的JSON结果字符串
                通过检查JSON前后是否有HTML标签来判断
                """
                # 找到所有匹配的JSON
                matches = list(re.finditer(json_result_pattern, text, re.DOTALL))
                if not matches:
                    return text
                
                # 从后往前移除，避免索引变化
                result = text
                for match in reversed(matches):
                    start, end = match.span()
                    
                    # 检查前后100个字符是否有HTML标签
                    before_context = text[max(0, start-100):start]
                    after_context = text[end:min(len(text), end+100)]
                    
                    # 检查是否在HTML标签内：
                    # 1. 如果before中有未闭合的<（最近的一个<后面没有>），说明可能在HTML标签内
                    # 2. 如果after中有未闭合的>（最近的一个>前面没有<），说明可能在HTML标签内
                    # 3. 检查是否有<div、<span等HTML标签
                    
                    # 检查before中最后一个<的位置
                    last_lt_before = before_context.rfind('<')
                    last_gt_before = before_context.rfind('>')
                    
                    # 检查after中第一个>的位置
                    first_gt_after = after_context.find('>')
                    first_lt_after = after_context.find('<')
                    
                    # 判断是否在HTML标签内
                    # 如果before中有<但没有对应的>，或者after中有>但没有对应的<，可能在HTML内
                    is_in_html = False
                    
                    if last_lt_before > last_gt_before and last_lt_before >= 0:
                        # before中有未闭合的<
                        tag_content = before_context[last_lt_before:]
                        if any(tag in tag_content for tag in ['<div', '<span', '<table', '<script', '<style']):
                            is_in_html = True
                    
                    if first_gt_after >= 0 and (first_lt_after < 0 or first_gt_after < first_lt_after):
                        # after中有>但没有<
                        tag_content = after_context[:first_gt_after+1]
                        if any(tag in tag_content for tag in ['</div>', '</span>', '</table>', '</script>', '</style>']):
                            is_in_html = True
                    
                    # 如果不在HTML标签内，则移除
                    if not is_in_html:
                        result = result[:start] + result[end:]
                
                return result
            
            assistant_message = remove_json_results(assistant_message)
            
            # 移除可能的残留文本（如"ington"等，可能是"thinking"的一部分）
            # 只在开头匹配，避免误删重要内容
            assistant_message = re.sub(r'^[a-z]{1,20}\s+', '', assistant_message, flags=re.IGNORECASE).strip()
            
            # 移除多余的空白字符（但保留换行）
            assistant_message = re.sub(r'[ \t]+', ' ', assistant_message)
            
            # 清理多余的空行（保留单个换行）
            assistant_message = re.sub(r'\n\s*\n+', '\n\n', assistant_message).strip()
            
            # 恢复HTML部分
            if html_marker in assistant_message:
                assistant_message = assistant_message.replace(html_marker, html_results if html_results else "")
                logger.info(f"已恢复HTML部分，HTML长度: {len(html_results) if html_results else 0}")
            elif html_results:
                # 如果marker不存在但html_results存在，直接追加（可能清理过程中marker被误删）
                assistant_message += "\n\n" + html_results
                logger.info(f"HTML marker不存在，直接追加HTML，HTML长度: {len(html_results)}")
            
            # 最终检查：确保HTML在消息中
            if html_results:
                html_in_message = html_results in assistant_message or 'search-results-container' in assistant_message
                logger.info(f"最终检查：HTML是否在消息中: {html_in_message}, 消息长度: {len(assistant_message)}, HTML中包含按钮数: {html_results.count('view-3d-structure-btn')}")
            
            # 简单判断意图（qwen-agent内部已经处理了工具调用）
            assistant_lower = assistant_message.lower()
            if "搜索" in assistant_message and ("蛋白" in assistant_message or "protein" in assistant_lower):
                intent = "search_protein"
            elif "blast" in assistant_lower or "序列" in assistant_message:
                intent = "blast_sequence_search"
            elif "foldseek" in assistant_lower or "结构" in assistant_message:
                intent = "foldseek_structure_search"
            
        except Exception as e:
            logger.error(f"qwen-agent处理失败: {str(e)}", exc_info=True)
            assistant_message = f"抱歉，处理消息时出现错误: {str(e)}"
            intent = "general_chat"
        
        # 6. 保存助手回复
        with engine.begin() as conn:
            conn.execute(
                text("""
                    INSERT INTO chat_messages (session_id, role, content, intent, tool_calls)
                    VALUES (:session_id, 'assistant', :content, :intent, :tool_calls)
                """),
                {
                    "session_id": session_id,
                    "content": assistant_message,
                    "intent": intent,
                    "tool_calls": json.dumps(tool_results) if tool_results else None
                }
            )
        
        # 8. 更新会话时间
        with engine.begin() as conn:
            conn.execute(
                text("""
                    UPDATE chat_sessions 
                    SET updated_at = CURRENT_TIMESTAMP 
                    WHERE session_id = :session_id
                """),
                {"session_id": session_id}
            )
        
        logger.info(f"消息处理完成,会话: {session_id}")
        
        # 最终日志：记录返回给前端的消息内容摘要
        logger.info(f"返回给前端的消息长度: {len(assistant_message)}")
        logger.info(f"返回给前端的消息前200字符: {assistant_message[:200]}")
        logger.info(f"返回给前端的消息是否包含search-results-container: {'search-results-container' in assistant_message}")
        logger.info(f"返回给前端的消息是否包含view-3d-structure-btn: {'view-3d-structure-btn' in assistant_message}")
        
        return {
            "success": True,
            "session_id": session_id,
            "response": assistant_message,
            "intent": intent,
            "tool_calls": tool_results if tool_results else None
        }
        
    except Exception as e:
        logger.error(f"处理聊天消息失败: {str(e)}", exc_info=True)
        raise HTTPException(status_code=500, detail=f"处理失败: {str(e)}")


@app.get("/api/chat/history")
async def get_chat_history(session_id: str, client_session_id: str = Query(..., description="客户端会话ID")):
    """
    获取对话历史
    
    Args:
        session_id: 会话ID
        client_session_id: 客户端会话ID（用于验证所有权）
    
    Returns:
        会话信息和消息列表
    """
    try:
        # 验证 client_session_id
        if not client_session_id:
            raise HTTPException(status_code=400, detail="缺少客户端会话ID（client_session_id）")
        
        with engine.connect() as conn:
            # 获取会话信息（验证所有权）
            session_result = conn.execute(
                text("""
                    SELECT session_id, title, created_at, updated_at, client_session_id
                    FROM chat_sessions
                    WHERE session_id = :session_id AND client_session_id = :client_session_id
                """),
                {"session_id": session_id, "client_session_id": client_session_id}
            )
            session_row = session_result.fetchone()
            
            if not session_row:
                raise HTTPException(status_code=404, detail="会话不存在或无权限访问")
            
            session = {
                "session_id": session_row[0],
                "title": session_row[1],
                "created_at": str(session_row[2]),
                "updated_at": str(session_row[3])
            }
            
            # 获取消息列表
            messages_result = conn.execute(
                text("""
                    SELECT id, role, content, intent, tool_calls, citations, created_at
                    FROM chat_messages
                    WHERE session_id = :session_id
                    ORDER BY created_at ASC
                """),
                {"session_id": session_id}
            )
            
            messages = []
            for row in messages_result:
                messages.append({
                    "id": row[0],
                    "role": row[1],
                    "content": row[2],
                    "intent": row[3],
                    "tool_calls": json.loads(row[4]) if row[4] else None,
                    "citations": json.loads(row[5]) if row[5] else None,
                    "created_at": str(row[6])
                })
        
        return {
            "success": True,
            "session": session,
            "messages": messages
        }
        
    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"获取历史记录失败: {str(e)}")
        raise HTTPException(status_code=500, detail=f"获取失败: {str(e)}")


@app.get("/api/chat/sessions")
async def list_chat_sessions(
    page: int = 1, 
    page_size: int = 20,
    client_session_id: str = Query(..., description="客户端会话ID")
):
    """
    获取会话列表
    
    Args:
        page: 页码(从1开始)
        page_size: 每页数量
        client_session_id: 客户端会话ID（用于过滤会话）
    
    Returns:
        会话列表（只返回当前客户端的会话）
    """
    try:
        # 验证 client_session_id
        if not client_session_id:
            raise HTTPException(status_code=400, detail="缺少客户端会话ID（client_session_id）")
        
        offset = (page - 1) * page_size
        
        with engine.connect() as conn:
            # 获取总数（只统计当前客户端的会话）
            total_result = conn.execute(
                text("SELECT COUNT(*) FROM chat_sessions WHERE client_session_id = :client_session_id"),
                {"client_session_id": client_session_id}
            )
            total = total_result.scalar()
            
            # 获取会话列表（只返回当前客户端的会话）
            sessions_result = conn.execute(
                text("""
                    SELECT 
                        s.session_id,
                        s.title,
                        s.created_at,
                        s.updated_at,
                        COUNT(m.id) as message_count
                    FROM chat_sessions s
                    LEFT JOIN chat_messages m ON s.session_id = m.session_id
                    WHERE s.client_session_id = :client_session_id
                    GROUP BY s.session_id, s.title, s.created_at, s.updated_at
                    ORDER BY s.updated_at DESC
                    LIMIT :limit OFFSET :offset
                """),
                {"client_session_id": client_session_id, "limit": page_size, "offset": offset}
            )
            
            sessions = []
            for row in sessions_result:
                sessions.append({
                    "session_id": row[0],
                    "title": row[1],
                    "created_at": str(row[2]),
                    "updated_at": str(row[3]),
                    "message_count": row[4]
                })
        
        return {
            "success": True,
            "total": total,
            "page": page,
            "page_size": page_size,
            "total_pages": (total + page_size - 1) // page_size,
            "sessions": sessions
        }
        
    except Exception as e:
        logger.error(f"获取会话列表失败: {str(e)}")
        raise HTTPException(status_code=500, detail=f"获取失败: {str(e)}")


@app.delete("/api/chat/session/{session_id}")
async def delete_chat_session(
    session_id: str,
    client_session_id: str = Query(..., description="客户端会话ID")
):
    """
    删除会话
    
    Args:
        session_id: 会话ID
        client_session_id: 客户端会话ID（用于验证所有权）
    
    注意: 会级联删除所有消息
    """
    try:
        # 验证 client_session_id
        if not client_session_id:
            raise HTTPException(status_code=400, detail="缺少客户端会话ID（client_session_id）")
        
        with engine.begin() as conn:
            # 检查会话是否存在且属于当前客户端
            result = conn.execute(
                text("""
                    SELECT session_id FROM chat_sessions 
                    WHERE session_id = :session_id AND client_session_id = :client_session_id
                """),
                {"session_id": session_id, "client_session_id": client_session_id}
            )
            
            if not result.fetchone():
                raise HTTPException(status_code=404, detail="会话不存在或无权限删除")
            
            # 删除会话(级联删除消息)
            conn.execute(
                text("DELETE FROM chat_sessions WHERE session_id = :session_id AND client_session_id = :client_session_id"),
                {"session_id": session_id, "client_session_id": client_session_id}
            )
        
        logger.info(f"会话已删除: {session_id}")
        
        return {
            "success": True,
            "message": "会话已删除"
        }
        
    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"删除会话失败: {str(e)}")
        raise HTTPException(status_code=500, detail=f"删除失败: {str(e)}")


@app.post("/api/chat/clear-all")
async def clear_all_sessions(client_session_id: str = Query(..., description="客户端会话ID")):
    """
    清空当前客户端的所有会话
    
    Args:
        client_session_id: 客户端会话ID（只清空该客户端的会话）
    
    注意: 只清空当前客户端的会话，不会影响其他客户端
    """
    try:
        # 验证 client_session_id
        if not client_session_id:
            raise HTTPException(status_code=400, detail="缺少客户端会话ID（client_session_id）")
        
        with engine.begin() as conn:
            # 只删除当前客户端的会话
            result = conn.execute(
                text("DELETE FROM chat_sessions WHERE client_session_id = :client_session_id"),
                {"client_session_id": client_session_id}
            )
            deleted_count = result.rowcount
        
        logger.warning(f"已清空客户端 {client_session_id} 的 {deleted_count} 个会话")
        
        return {
            "success": True,
            "message": f"已清空 {deleted_count} 个会话",
            "deleted_count": deleted_count
        }
        
    except Exception as e:
        logger.error(f"清空会话失败: {str(e)}")
        raise HTTPException(status_code=500, detail=f"清空失败: {str(e)}")


# ============================================================================
# 主程序入口
# ============================================================================

if __name__ == "__main__":
    import uvicorn
    # 配置uvicorn超时设置（支持长时间运行的BLAST和Foldseek搜索）
    uvicorn.run(
        app, 
        host=BACKEND_HOST, 
        port=BACKEND_PORT,
        timeout_keep_alive=300,  # 保持连接超时（5分钟）
        timeout_graceful_shutdown=30  # 优雅关闭超时
    )